diff --git a/.betterer.results b/.betterer.results index 130ed780c5b..fbe46404e01 100644 --- a/.betterer.results +++ b/.betterer.results @@ -44,7 +44,7 @@ exports[`no enzyme tests`] = { "packages/jaeger-ui-components/src/TraceTimelineViewer/ListView/index.test.js:1734982398": [ [14, 26, 13, "RegExp match", "2409514259"] ], - "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.js:700147304": [ + "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.js:1241675783": [ [16, 19, 13, "RegExp match", "2409514259"] ], "packages/jaeger-ui-components/src/TraceTimelineViewer/SpanTreeOffset.test.js:174536706": [ @@ -65,9 +65,6 @@ exports[`no enzyme tests`] = { "packages/jaeger-ui-components/src/TraceTimelineViewer/index.test.js:276996587": [ [14, 19, 13, "RegExp match", "2409514259"] ], - "public/app/core/components/QueryOperationRow/QueryOperationRow.test.tsx:3743889097": [ - [0, 26, 13, "RegExp match", "2409514259"] - ], "public/app/core/components/Select/MetricSelect.test.tsx:1074737147": [ [0, 19, 13, "RegExp match", "2409514259"] ], @@ -3114,6 +3111,12 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], + "public/app/core/components/PageNew/PageTabs.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/core/components/PageNew/SectionNav.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], "public/app/core/components/PanelTypeFilter/PanelTypeFilter.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] @@ -3125,10 +3128,6 @@ exports[`better eslint`] = { "public/app/core/components/PermissionList/PermissionList.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/core/components/QueryOperationRow/QueryOperationRow.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/core/components/RolePicker/RolePickerMenu.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -3212,17 +3211,8 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "9"] ], "public/app/core/logsModel.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.", "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, "Do not use any type assertions.", "0"], + [0, 0, 0, "Do not use any type assertions.", "1"] ], "public/app/core/mod_defs.d.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -4532,8 +4522,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.", "5"] ], "public/app/features/dashboard/state/DashboardMigrator.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], @@ -5728,6 +5717,56 @@ exports[`better eslint`] = { "public/app/features/sandbox/TestStuffPage.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], + "public/app/features/scenes/components/SceneFlexLayout.tsx:5381": [ + [0, 0, 0, "Do not use any type assertions.", "0"] + ], + "public/app/features/scenes/core/SceneComponentEditWrapper.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.", "3"] + ], + "public/app/features/scenes/core/SceneObjectBase.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, "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, "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, "Do not use any type assertions.", "9"], + [0, 0, 0, "Unexpected any. Specify a different type.", "10"] + ], + "public/app/features/scenes/core/SceneTimeRange.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/scenes/core/events.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/scenes/core/types.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/scenes/editor/SceneObjectTree.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.", "2"] + ], + "public/app/features/scenes/querying/SceneQueryRunner.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"], + [0, 0, 0, "Do not use any type assertions.", "3"], + [0, 0, 0, "Do not use any type assertions.", "4"] + ], + "public/app/features/scenes/variables/types.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "public/app/features/search/components/SearchCard.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"], @@ -5808,6 +5847,20 @@ 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/storage/StoragePage.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/storage/helper.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, "Do not use any type assertions.", "3"], + [0, 0, 0, "Unexpected any. Specify a different type.", "4"] + ], + "public/app/features/teams/CreateTeam.test.tsx:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "public/app/features/teams/TeamGroupSync.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] @@ -8929,9 +8982,7 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "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, "Do not use any type assertions.", "18"], - [0, 0, 0, "Unexpected any. Specify a different type.", "19"] + [0, 0, 0, "Unexpected any. Specify a different type.", "17"] ], "public/app/plugins/panel/geomap/components/DataHoverRows.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] @@ -10300,9 +10351,6 @@ exports[`no undocumented stories`] = { "packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx:5381": [ [0, 0, 0, "No undocumented stories are allowed, please add an .mdx file with some documentation", "5381"] ], - "packages/grafana-ui/src/components/PluginSignatureBadge/PluginSignatureBadge.story.tsx:5381": [ - [0, 0, 0, "No undocumented stories are allowed, please add an .mdx file with some documentation", "5381"] - ], "packages/grafana-ui/src/components/QueryField/QueryField.story.tsx:5381": [ [0, 0, 0, "No undocumented stories are allowed, please add an .mdx file with some documentation", "5381"] ], diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4f1486946fd..ad9fff7905b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -108,6 +108,7 @@ pkg/tsdb/testdatasource/sims/ @grafana/grafana-edge-squad /public/app/features/comments/ @grafana/grafana-edge-squad /public/app/features/dimensions/ @grafana/grafana-edge-squad /public/app/features/geo/ @grafana/grafana-edge-squad +/public/app/features/storage/ @grafana/grafana-edge-squad /public/app/features/live/ @grafana/grafana-edge-squad /public/app/features/explore/ @grafana/observability-experience-squad /public/app/features/plugins @grafana/plugins-platform-frontend diff --git a/.github/workflows/doc-validator.yml b/.github/workflows/doc-validator.yml new file mode 100644 index 00000000000..63a2ef54dcb --- /dev/null +++ b/.github/workflows/doc-validator.yml @@ -0,0 +1,16 @@ +name: "doc-validator" +on: + pull_request: + paths: ["docs/sources/**"] + workflow_dispatch: +jobs: + doc-validator: + runs-on: "ubuntu-latest" + container: + image: "grafana/doc-validator:latest" + steps: + - name: "Checkout code" + uses: "actions/checkout@v3" + - name: "Run doc-validator tool" + # Ensure that the CI always passes until all errors are resolved. + run: "doc-validator ./docs/sources || true" diff --git a/devenv/docker/blocks/influxdb/config.yaml b/devenv/docker/blocks/influxdb/config.yaml deleted file mode 100644 index 15dd892ce27..00000000000 --- a/devenv/docker/blocks/influxdb/config.yaml +++ /dev/null @@ -1 +0,0 @@ -http-bind-address: :8086 \ No newline at end of file diff --git a/devenv/docker/blocks/influxdb/docker-compose.yaml b/devenv/docker/blocks/influxdb/docker-compose.yaml index 803eb4be511..244bb472107 100644 --- a/devenv/docker/blocks/influxdb/docker-compose.yaml +++ b/devenv/docker/blocks/influxdb/docker-compose.yaml @@ -5,6 +5,7 @@ - '8086:8086' environment: INFLUXD_REPORTING_DISABLED: 'true' + INFLUXD_HTTP_BIND_ADDRESS: ':8086' DOCKER_INFLUXDB_INIT_MODE: 'setup' DOCKER_INFLUXDB_INIT_USERNAME: 'grafana' DOCKER_INFLUXDB_INIT_PASSWORD: 'grafana12345' @@ -12,7 +13,6 @@ DOCKER_INFLUXDB_INIT_BUCKET: 'mybucket' DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: 'mytoken' volumes: - - ./docker/blocks/influxdb/config.yaml:/etc/influxdb2/config.yaml - ./docker/blocks/influxdb/setup_influxql.sh:/docker-entrypoint-initdb.d/setup_influxql.sh telegraf: diff --git a/docs/sources/administration/provisioning/_index.md b/docs/sources/administration/provisioning/_index.md index 721a565aa34..befc09072bc 100644 --- a/docs/sources/administration/provisioning/_index.md +++ b/docs/sources/administration/provisioning/_index.md @@ -233,7 +233,10 @@ datasources: > This feature is available from v7.1 -You can manage plugins in Grafana by adding one or more YAML config files in the [`provisioning/plugins`]({{< relref "../../setup-grafana/configure-grafana/#provisioning" >}}) directory. Each config file can contain a list of `apps` that will be updated during start up. Grafana updates each app to match the configuration file. +You can manage plugin applications in Grafana by adding one or more YAML config files in the [`provisioning/plugins`]({{< relref "../../setup-grafana/configure-grafana/#provisioning" >}}) directory. Each config file can contain a list of `apps` that will be updated during start up. Grafana updates each app to match the configuration file. + +> **Note:** This feature enables you to provision plugin configurations, not the plugins themselves. +> The plugins must already be installed on the grafana instance ### Example plugin configuration file diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions.md index b69f9c702b0..28e3437b11f 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions.md @@ -74,7 +74,7 @@ The following tables list permissions associated with basic and fixed roles. | `fixed:settings:reader` | `settings:read` | Read Grafana instance settings. | | `fixed:settings:writer` | All permissions from `fixed:settings:reader` and
`settings:write` | Read and update Grafana instance settings. | | `fixed:stats:reader` | `server.stats:read` | Read Grafana instance statistics. | -| `fixed:teams:creator` | `teams:create`
`org.users:read` | Create a team and list organization users (required to manage the created team). | +| `fixed:teams:creator` | `teams:create`
`org.users:read`
`serviceaccounts:read` | Create a team and list organization users and service accounts (required to manage the created team). | | `fixed:teams:writer` | `teams:create`
`teams:delete`
`teams:read`
`teams:write`
`teams.permissions:read`
`teams.permissions:write` | Create, read, update and delete teams and manage team memberships. | | `fixed:users:reader` | `users:read`
`users.quotas:read`
`users.authtoken:read`
` | Read all users and their information, such as team memberships, authentication tokens, and quotas. | | `fixed:users:writer` | All permissions from `fixed:users:reader` and
`users:write`
`users:create`
`users:delete`
`users:enable`
`users:disable`
`users.password:write`
`users.permissions:write`
`users:logout`
`users.authtoken:write`
`users.quotas:write` | Read and update all attributes and settings for all users in Grafana: update user information, read user information, create or enable or disable a user, make a user a Grafana administrator, sign out a user, update a user’s authentication token, or update quotas for all users. | diff --git a/docs/sources/alerting/fundamentals/annotation-label/example-template-functions.md b/docs/sources/alerting/fundamentals/annotation-label/example-template-functions.md index 3bd894057ab..b6f13464476 100644 --- a/docs/sources/alerting/fundamentals/annotation-label/example-template-functions.md +++ b/docs/sources/alerting/fundamentals/annotation-label/example-template-functions.md @@ -1,7 +1,7 @@ --- aliases: + - /docs/grafana/latest/alerting/contact-points/message-templating/example-template-functions/ - /docs/grafana/latest/alerting/fundamentals/annotation-label/example-template-functions/ - - /docs/grafana/latest/alerting/unified-alerting/fundamentals/annotation-label/template-functions/ keywords: - grafana - alerting diff --git a/docs/sources/alerting/fundamentals/annotation-label/template-functions.md b/docs/sources/alerting/fundamentals/annotation-label/template-functions.md index b06bb916045..c7c3635c525 100644 --- a/docs/sources/alerting/fundamentals/annotation-label/template-functions.md +++ b/docs/sources/alerting/fundamentals/annotation-label/template-functions.md @@ -1,5 +1,8 @@ --- aliases: + - /docs/grafana/latest/alerting/contact-points/message-templating/template-functions/ + - /docs/grafana/latest/alerting/message-templating/template-functions/ + - /docs/grafana/latest/alerting/unified-alerting/message-templating/template-functions/ - /docs/grafana/latest/alerting/fundamentals/annotation-label/template-functions/ - /docs/grafana/latest/alerting/unified-alerting/fundamentals/annotation-label/template-functions/ keywords: diff --git a/docs/sources/best-practices/best-practices-for-creating-dashboards.md b/docs/sources/best-practices/best-practices-for-creating-dashboards.md index 932946faa84..91b765d8e20 100644 --- a/docs/sources/best-practices/best-practices-for-creating-dashboards.md +++ b/docs/sources/best-practices/best-practices-for-creating-dashboards.md @@ -51,6 +51,6 @@ Once you have a strategy or design guidelines, write them down to help maintain - Use the left and right Y-axes when displaying time series with different units or ranges. - Add documentation to dashboards and panels. - To add documentation to a dashboard, add a [Text panel visualization]({{< relref "../visualizations/text-panel/" >}}) to the dashboard. Record things like the purpose of the dashboard, useful resource links, and any instructions users might need to interact with the dashboard. Check out this [Wikimedia example](https://grafana.wikimedia.org/d/000000066/resourceloader?orgId=1). - - To add documentation to a panel, [edit the panel settings]({{< relref "../panels/working-with-panels/add-panel/" >}}) and add a description. Any text you add will appear if you hover your cursor over the small `i` in the top left corner of the panel. + - To add documentation to a panel, edit the panel settings and add a description. Any text you add will appear if you hover your cursor over the small `i` in the top left corner of the panel. - Reuse your dashboards and enforce consistency by using [templates and variables]({{< relref "../variables/" >}}). - Be careful with stacking graph data. The visualizations can be misleading, and hide important data. We recommend turning it off in most cases. diff --git a/docs/sources/dashboards/add-organize-panels.md b/docs/sources/dashboards/add-organize-panels.md new file mode 100644 index 00000000000..52854224f1c --- /dev/null +++ b/docs/sources/dashboards/add-organize-panels.md @@ -0,0 +1,108 @@ +--- +aliases: + - /docs/grafana/latest/panels/working-with-panels/navigate-panel-editor/ + - /docs/grafana/latest/panels/working-with-panels/navigate-inspector-panel/ + - /docs/grafana/latest/dashboards/dashboard-create/ + - /docs/grafana/latest/features/dashboard/dashboards/ + - /docs/grafana/latest/panels/working-with-panels/add-panel/ + - /docs/grafana/latest/dashboards/add-organize-panels/ +title: Add and organize panels +menuTitle: Add and organize panels +weight: 2 +--- + +# Add and organize panels + +This section describes the areas of the Grafana panel editor. + +1. Panel header: The header section lists the dashboard in which the panel appears and the following controls: + + - **Dashboard settings (gear) icon -** Click to access the dashboard settings. + - **Discard -** Discards changes you have made to the panel since you last saved the dashboard. + - **Save -** Saves changes you made to the panel. + - **Apply -** Applies changes you made and closes the panel editor, returning you to the dashboard. You will have to save the dashboard to persist the applied changes. + +1. Visualization preview: The visualization preview section contains the following options: + + - **Table view -** Convert any visualization to a table so that you can see the data. Table views are useful for troubleshooting. + - **Fill -** The visualization preview fills the available space. If you change the width of the side pane or height of the bottom pane the visualization changes to fill the available space. + - **Actual -** The visualization preview will have the exact size as the size on the dashboard. If not enough space is available, the visualization will scale down preserving the aspect ratio. + - **Time range controls -** For more information, refer to [Time range controls]({{< relref "time-range-controls/" >}}). + +1. Data section: The data section contains tabs where you enter queries, transform your data, and create alert rules (if applicable). + + - **Query tab -** Select your data source and enter queries here. For more information, refer to [Add a query]({{< relref "../panels/query-a-data-source/add-a-query/" >}}). + - **Transform tab -** Apply data transformations. For more information, refer to [Transform data]({{< relref "../panels/transform-data/" >}}). + - **Alert tab -** Write alert rules. For more information, refer to [Overview of Grafana 8 alerting]({{< relref "../alerting/" >}}). + +1. Panel display options: The display options section contains tabs where you configure almost every aspect of your data visualization, including: + + - [Apply color to series and fields]({{< relref "../panels/working-with-panels/apply-color-to-series/" >}}) + - [Format a standard field]({{< relref "../panels/working-with-panels/format-standard-fields/" >}}) + - [Add a title and description to a panel]({{< relref "../panels/working-with-panels/add-title-and-description/" >}}) + +> Not all options are available for each visualization. + +{{< figure src="/static/img/docs/panel-editor/panel-editor-8-0.png" class="docs-image--no-shadow" max-width="1500px" >}} + +## Open the panel inspect drawer + +The inspect drawer helps you understand and troubleshoot your panels. You can view the raw data for any panel, export that data to a comma-separated values (CSV) file, view query requests, and export panel and data JSON. + +> **Note:** Not all panel types include all tabs. For example, dashboard list panels do not have raw data to inspect, so they do not display the Stats, Data, or Query tabs. + +The panel inspector consists of the following options: + +1. The panel inspect drawer displays opens a drawer on the right side. Click the arrow in the upper right corner to expand or reduce the drawer pane. + +1. **Data tab -** Shows the raw data returned by the query with transformations applied. Field options such as overrides and value mappings are not applied by default. + +1. **Stats tab -** Shows how long your query takes and how much it returns. + +1. **JSON tab -** Allows you to view and copy the panel JSON, panel data JSON, and data frame structure JSON. This is useful if you are provisioning or administering Grafana. + +1. **Query tab -** Shows you the requests to the server sent when Grafana queries the data source. + +1. **Error tab -** Shows the error. Only visible when query returns error. + +## Create a dashboard and add a panel + +Dashboards and panels allow you to show your data in visual form. Each panel needs at least one query to display a visualization. + +**Before you begin:** + +- Ensure that you have the proper permissions. For more information about permissions, refer to [About users and permissions]({{< relref "../administration/roles-and-permissions/" >}}). +- Identify the dashboard to which you want to add the panel. +- Understand the query language of the target data source. +- Ensure that data source for which you are writing a query has been added. For more information about adding a data source, refer to [Add a data source]({{< relref "../datasources/add-a-data-source/" >}}) if you need instructions. + +**To create a dashboard and add a panel**: + +1. Sign in to Grafana, hover your cursor over **Dashboard**, and click **+ New Dashboard**. +1. Click **Add a new panel**. +1. In the first line of the **Query** tab, click the drop-down list and select a data source. +1. Write or construct a query in the query language of your data source. + + For more information about data sources, refer to [Data sources]({{< relref "../datasources/" >}}) for specific guidelines. + +1. In the Visualization list, select a visualization type. + + Grafana displays a preview of your query results with the visualization applied. + + ![](/static/img/docs/panel-editor/select-visualization-8-0.png) + + For more information about individual visualizations, refer to [Visualizations options]({{< relref "../visualizations/" >}}). + +1. Refer to the following documentation for ways you can adjust panel settings. + + While not required, most visualizations need some adjustment before they properly display the information that you need. + + - [Format data using value mapping]({{< relref "../panels/format-data/about-value-mapping/" >}}) + - [Visualization-specific options]({{< relref "../visualizations/" >}}) + - [Override field values]({{< relref "../panels/override-field-values/about-field-overrides/" >}}) + - [Configure thresholds]({{< relref "../panels/configure-thresholds/" >}}) + - [Apply color to series and fields]({{< relref "../panels/working-with-panels/apply-color-to-series/" >}}) + +1. Add a note to describe the visualization (or describe your changes) and then click **Save** in the upper-right corner of the page. + + Notes can be helpful if you need to revert the dashboard to a previous version. diff --git a/docs/sources/dashboards/manage-library-panels.md b/docs/sources/dashboards/manage-library-panels.md new file mode 100644 index 00000000000..12b718837ae --- /dev/null +++ b/docs/sources/dashboards/manage-library-panels.md @@ -0,0 +1,87 @@ +--- +aliases: + - /docs/grafana/latest/panels/panel-library/ + - /docs/grafana/latest/panels/library-panels/ + - /docs/grafana/latest/panels/library-panels/create-library-panel/ + - /docs/grafana/latest/panels/library-panels/add-library-panel/ + - /docs/grafana/latest/panels/library-panels/unlink-library-panel/ + - /docs/grafana/latest/panels/library-panels/manage-library-panel/ + - /docs/grafana/latest/panels/library-panels/delete-library-panel/ + - /docs/grafana/latest/dashboards/manage-library-panels/ +title: Manage Grafana library panels +menuTitle: Manage library panels +weight: 3 +--- + +# Manage Grafana library panels + +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. + +You can save a library panel in a folder alongside saved dashboards. + +## 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. + +1. Open a panel in edit mode. +1. In the panel display options, click the down arrow option to bring changes to the visualization. + {{< figure src="/static/img/docs/library-panels/create-lib-panel-from-edit-8-0.png" class="docs-image--no-shadow" max-width= "800px" caption="Screenshot of the edit panel" >}} +1. Click the **Library panels** option, and then click **Create library panel** to open the create dialog. + {{< figure src="/static/img/docs/library-panels/create-lib-panel-8-0.png" class="docs-image--no-shadow" max-width= "500px" caption="Screenshot of the create library panel dialog" >}} +1. In **Library panel name**, enter the name. +1. In **Save in folder**, select the folder to save the library panel. +1. Click **Create library panel** to save your changes. +1. Save the dashboard. + +Once created, you can modify the library panel using any dashboard on which it appears. After you save the changes, all instances of the library panel reflect these modifications. + +{{< figure src="/static/img/docs/library-panels/create-from-more-8-0.png" class="docs-image--no-shadow" max-width= "900px" caption="Screenshot of the edit panel" >}} + +## Add a library panel to a dashboard + +Add a Grafana library panel to a dashboard when you want to provide visualizations to other dashboard users. + +1. Hover over the **Dashboards** option on the left menu, then select **New dashboard** from the drop-down options. + + The **Add** panel dialog opens. + {{< figure src="/static/img/docs/library-panels/add-library-panel-8-0.png" class="docs-image--no-shadow" max-width= "900px" caption="Screenshot of the edit panel" >}} + +1. Click the **Add a panel from the panel library** option. + + You will see a list of your library panels. + +1. Filter the list or search to find the panel you want to add. +1. Click a panel to add it to the dashboard. + +## Unlink a library panel + +Unlink a library panel when you want to make a change to the panel and not affect other instances of the library panel. + +1. Hover over **Dashboard** on the left menu, and then click **Library panels**. +1. Select a library panel that is being used in different dashboards. +1. Select the panel you want to unlink. +1. Click the title of the panel and then click **Edit**. The panel opens in edit mode. +1. Click the **Unlink** option on the top right corner of the page. + +## View a list of library panels + +You can view a list of available library panels and search for a library panel. + +1. Hover over the **Dashboard** option on the left menu, then click **Library panels**. + + You can see a list of previously defined library panels. + {{< figure src="/static/img/docs/library-panels/library-panel-list-8-0.png" class="docs-image--no-shadow" max-width= "900px" caption="Screenshot of the edit panel" >}} + +1. Search for a specific library panel if you know its name. + + You can also filter the panels by folder or type. + +## Delete a library panel + +Delete a library panel when you no longer need it. + +1. Hover over **Dashboard** on the left menu, and select **Library panels**. + +1. Select the panel you want to delete. + +1. Click the delete icon next to the library panel name. diff --git a/docs/sources/dashboards/use-dashboards.md b/docs/sources/dashboards/use-dashboards.md index b8c11c162f6..213ae424317 100644 --- a/docs/sources/dashboards/use-dashboards.md +++ b/docs/sources/dashboards/use-dashboards.md @@ -10,7 +10,7 @@ aliases: - /docs/grafana/latest/reference/search/ title: 'Use dashboards' menuTitle: Use dashboards -weight: 2 +weight: 1 keywords: - dashboard - search diff --git a/docs/sources/datasources/azuremonitor/_index.md b/docs/sources/datasources/azuremonitor/_index.md index 095824c0798..3df80deaf61 100644 --- a/docs/sources/datasources/azuremonitor/_index.md +++ b/docs/sources/datasources/azuremonitor/_index.md @@ -103,7 +103,7 @@ Further documentation on multi-dimensional metrics is available [here](https://d #### Supported Azure Monitor metrics -Not all metrics returned by the Azure Monitor Metrics API have values. To make it easier for you when building a query, the Grafana data source has a list of supported metrics and ignores metrics which will never have values. This list is updated regularly as new services and metrics are added to the Azure cloud. For more information about the list of metrics, refer to [current supported namespaces](https://github.com/grafana/grafana/blob/main/public/app/plugins/datasource/grafana-azure-monitor-datasource/azure_monitor/supported_namespaces.ts). +Not all metrics returned by the Azure Monitor Metrics API have values. To make it easier for you when building a query, the Grafana data source has a list of supported metrics and ignores metrics which will never have values. This list is updated regularly as new services and metrics are added to the Azure cloud. For more information about the list of metrics, refer to [current supported namespaces](https://github.com/grafana/grafana/blob/main/public/app/plugins/datasource/grafana-azure-monitor-datasource/azureMetadata/metricNamespaces.ts). ### Querying Azure Monitor Logs diff --git a/docs/sources/datasources/jaeger.md b/docs/sources/datasources/jaeger.md index 20ab026c23f..8c67c20fc4d 100644 --- a/docs/sources/datasources/jaeger.md +++ b/docs/sources/datasources/jaeger.md @@ -66,6 +66,16 @@ This is a configuration for the beta Node Graph visualization. The Node Graph is -- **Enable Node Graph -** Enables the Node Graph visualization. +### Span bar label + +You can configure the span bar label. The span bar label allows you add additional information to the span bar row. + +Select one of the following four options. The default selection is Duration. + +- **None -** Do not show any additional information on the span bar row. +- **Duration -** Show the span duration on the span bar row. +- **Tag -** Show the span tag on the span bar row. Note: You will also need to specify the tag key to use to get the tag value. For example, `span.kind`. + ## Query traces You can query and display traces from Jaeger via [Explore]({{< relref "../explore/" >}}). diff --git a/docs/sources/datasources/tempo.md b/docs/sources/datasources/tempo.md index 5c5ad785c87..8442e65f72f 100644 --- a/docs/sources/datasources/tempo.md +++ b/docs/sources/datasources/tempo.md @@ -84,6 +84,16 @@ This is a configuration for the Loki search query type. -- **Data source -** The Loki instance in which you want to search traces. You must configure derived fields in the Loki instance. +### Span bar label + +You can configure the span bar label. The span bar label allows you add additional information to the span bar row. + +Select one of the following four options. The default selection is Duration. + +- **None -** Do not show any additional information on the span bar row. +- **Duration -** Show the span duration on the span bar row. +- **Tag -** Show the span tag on the span bar row. Note: You will also need to specify the tag key to use to get the tag value. For example, `span.kind`. + ## Query traces You can query and display traces from Tempo via [Explore]({{< relref "../explore/" >}}). diff --git a/docs/sources/datasources/zipkin.md b/docs/sources/datasources/zipkin.md index d9fbcdd53c5..48a543cf8ce 100644 --- a/docs/sources/datasources/zipkin.md +++ b/docs/sources/datasources/zipkin.md @@ -65,6 +65,16 @@ This is a configuration for the beta Node Graph visualization. The Node Graph is -- **Enable Node Graph -** Enables the Node Graph visualization. +### Span bar label + +You can configure the span bar label. The span bar label allows you add additional information to the span bar row. + +Select one of the following four options. The default selection is Duration. + +- **None -** Do not show any additional information on the span bar row. +- **Duration -** Show the span duration on the span bar row. +- **Tag -** Show the span tag on the span bar row. Note: You will also need to specify the tag key to use to get the tag value. For example, `span.kind`. + ## Query traces Querying and displaying traces from Zipkin is available via [Explore]({{< relref "../explore/" >}}). diff --git a/docs/sources/panels/format-data/edit-value-mapping.md b/docs/sources/panels/format-data/edit-value-mapping.md index 4b2d3b2c13d..16ca9fc996f 100644 --- a/docs/sources/panels/format-data/edit-value-mapping.md +++ b/docs/sources/panels/format-data/edit-value-mapping.md @@ -12,7 +12,6 @@ You can change a value mapping at any time. ## Before you begin -- [Add a panel to a dashboard]({{< relref "../working-with-panels/add-panel/" >}}). - Ensure you have an existing value mapping to edit. **To edit a value mapping**: diff --git a/docs/sources/panels/format-data/map-a-range.md b/docs/sources/panels/format-data/map-a-range.md index f11b22bf920..bc86d1fbc6e 100644 --- a/docs/sources/panels/format-data/map-a-range.md +++ b/docs/sources/panels/format-data/map-a-range.md @@ -10,12 +10,6 @@ weight: 30 Map a range of values when you want to format multiple, continuous values. -## Before you begin - -- [Add a panel to a dashboard]({{< relref "../working-with-panels/add-panel/" >}}). - -**To map a range**: - 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**. diff --git a/docs/sources/panels/format-data/map-a-regular-expression.md b/docs/sources/panels/format-data/map-a-regular-expression.md index 6e0792b9062..75879c7c91b 100644 --- a/docs/sources/panels/format-data/map-a-regular-expression.md +++ b/docs/sources/panels/format-data/map-a-regular-expression.md @@ -10,14 +10,8 @@ weight: 40 Map a regular expression when you want to format the text and color of a regular expression value. -## Before you begin - -- [Add a panel to a dashboard]({{< relref "../working-with-panels/add-panel/" >}}). - -**To map a regular expression**: - 1. Edit the panel for which you want to map a regular expression. -1. In the panel display options, in the **Value mappings** section, click **Add value mappings**. +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. diff --git a/docs/sources/panels/format-data/map-a-special-value.md b/docs/sources/panels/format-data/map-a-special-value.md index e7001a108a4..b4a3005f1c8 100644 --- a/docs/sources/panels/format-data/map-a-special-value.md +++ b/docs/sources/panels/format-data/map-a-special-value.md @@ -10,12 +10,6 @@ weight: 50 Map a special value when you want to format uncommon, boolean, or empty values. -## Before you begin - -- [Add a panel to a dashboard]({{< relref "../working-with-panels/add-panel/" >}}). - -**To map a special value**: - 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**. diff --git a/docs/sources/panels/format-data/map-a-value.md b/docs/sources/panels/format-data/map-a-value.md index db848227bb6..02e39594fe8 100644 --- a/docs/sources/panels/format-data/map-a-value.md +++ b/docs/sources/panels/format-data/map-a-value.md @@ -10,12 +10,6 @@ weight: 20 Map a value when you want to format a single value. -## Before you begin - -- [Add a panel to a dashboard]({{< relref "../working-with-panels/add-panel/" >}}). - -**To map a value**: - 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**. diff --git a/docs/sources/panels/library-panels/_index.md b/docs/sources/panels/library-panels/_index.md deleted file mode 100644 index 7e35d457ecf..00000000000 --- a/docs/sources/panels/library-panels/_index.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -aliases: - - /docs/grafana/latest/panels/library-panels/ - - /docs/grafana/latest/panels/panel-library/ - - /docs/sources/panels/library-panels/ -title: Create reusable Grafana panels -weight: 700 ---- - -# Create reusable Grafana panels - -Library panels enable you to create reusable panels that you can add to any dashboard in your organization. - -{{< section >}} diff --git a/docs/sources/panels/library-panels/about-library-panels.md b/docs/sources/panels/library-panels/about-library-panels.md deleted file mode 100644 index 5a1bd2c905a..00000000000 --- a/docs/sources/panels/library-panels/about-library-panels.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -aliases: - - /docs/grafana/latest/panels/library-panels/about-library-panels/ - - /docs/sources/panels/library-panels/about-library-panels/ -title: About Grafana library panels -weight: 10 ---- - -# About Grafana panel libraries - -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. - -You can save a library panel in a folder alongside saved dashboards. diff --git a/docs/sources/panels/library-panels/add-library-panel.md b/docs/sources/panels/library-panels/add-library-panel.md deleted file mode 100644 index 49daa67e8fc..00000000000 --- a/docs/sources/panels/library-panels/add-library-panel.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -aliases: - - /docs/grafana/latest/panels/library-panels/add-library-panel/ - - /docs/sources/panels/library-panels/add-library-panel/ -title: Add a Grafana library panel to a dashboard -weight: 30 ---- - -# Add a Grafana library panel to a dashboard - -Add a Grafana library panel to a dashboard when you want to provide visualizations to other dashboard users. - -## Before you begin - -- [Create a library panel]({{< relref "create-library-panel/" >}}). - -**To add a library panel to a dashboard**: - -1. Hover over the **Dashboards** option on the left menu, then select **New dashboard** from the drop-down options. - - The **Add** panel dialog opens. - {{< figure src="/static/img/docs/library-panels/add-library-panel-8-0.png" class="docs-image--no-shadow" max-width= "900px" caption="Screenshot of the edit panel" >}} - -1. Click the **Add a panel from the panel library** option. - - You will see a list of your library panels. - -1. Filter the list or search to find the panel you want to add. -1. Click a panel to add it to the dashboard. diff --git a/docs/sources/panels/library-panels/create-library-panel.md b/docs/sources/panels/library-panels/create-library-panel.md deleted file mode 100644 index d8f8d18eb97..00000000000 --- a/docs/sources/panels/library-panels/create-library-panel.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -aliases: - - /docs/grafana/latest/panels/library-panels/create-library-panel/ - - /docs/sources/panels/library-panels/create-library-panel/ -title: Create a Grafana library panel -weight: 20 ---- - -# Create a Grafana 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. - -## Before you begin - -- [Add a panel to a dashboard]({{< relref "../working-with-panels/add-panel/" >}}). - -**To create a library panel**: - -1. Open a panel in edit mode. -1. In the panel display options, click the down arrow option to bring changes to the visualization. - {{< figure src="/static/img/docs/library-panels/create-lib-panel-from-edit-8-0.png" class="docs-image--no-shadow" max-width= "800px" caption="Screenshot of the edit panel" >}} -1. Click the **Library panels** option, and then click **Create new library panel** to open the create dialog. - {{< figure src="/static/img/docs/library-panels/create-lib-panel-8-0.png" class="docs-image--no-shadow" max-width= "500px" caption="Screenshot of the create library panel dialog" >}} -1. In **Library panel name**, enter the name. -1. In **Save in folder**, select the folder to save the library panel. -1. Click **Create library panel** to save your changes. -1. Save the dashboard. - -Once created, you can modify the library panel using any dashboard on which it appears. After you save the changes, all instances of the library panel reflect these modifications. - -{{< figure src="/static/img/docs/library-panels/create-from-more-8-0.png" class="docs-image--no-shadow" max-width= "900px" caption="Screenshot of the edit panel" >}} diff --git a/docs/sources/panels/library-panels/delete-library-panel.md b/docs/sources/panels/library-panels/delete-library-panel.md deleted file mode 100644 index 2d799887449..00000000000 --- a/docs/sources/panels/library-panels/delete-library-panel.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -aliases: - - /docs/grafana/latest/panels/library-panels/delete-library-panel/ - - /docs/sources/panels/library-panels/delete-library-panel/ -title: Delete a Grafana library panel -weight: 60 ---- - -# Delete a Grafana library panel - -Delete a library panel when you no longer need it. - -## Before you begin - -- Verify that the panel does not appear on a dashboard. - -**To delete a library panel**: - -1. Hover over **Dashboard** on the left menu, and select Library panels from the drop-down options. -1. Select a library panel that is being used in different dashboards. - - You will see a list of all the dashboards. - -1. Select the panel you want to delete. -1. Click the delete icon next to the library panel name. diff --git a/docs/sources/panels/library-panels/manage-library-panel.md b/docs/sources/panels/library-panels/manage-library-panel.md deleted file mode 100644 index f15c9d4faca..00000000000 --- a/docs/sources/panels/library-panels/manage-library-panel.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -aliases: - - /docs/grafana/latest/panels/library-panels/manage-library-panel/ - - /docs/sources/panels/library-panels/manage-library-panel/ -title: Manage Grafana library panels -weight: 40 ---- - -# Manage Grafana library panels - -You can adjust library panel configuration at any time. - -## Before you begin - -- [Add a panel to a dashboard]({{< relref "../working-with-panels/add-panel/" >}}). - -**To view and manage a library panel**: - -1. Hover over the **Dashboard** option on the left menu, then click **Library panels**. - - You can see a list of previously defined library panels. - {{< figure src="/static/img/docs/library-panels/library-panel-list-8-0.png" class="docs-image--no-shadow" max-width= "900px" caption="Screenshot of the edit panel" >}} - -1. Search for a specific library panel if you know its name. - - You can also filter the panels by folder or type. diff --git a/docs/sources/panels/library-panels/unlink-library-panel.md b/docs/sources/panels/library-panels/unlink-library-panel.md deleted file mode 100644 index 06173090d11..00000000000 --- a/docs/sources/panels/library-panels/unlink-library-panel.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -aliases: - - /docs/grafana/latest/panels/library-panels/unlink-library-panel/ - - /docs/sources/panels/library-panels/unlink-library-panel/ -title: Unlink a Grafana library panel -weight: 50 ---- - -# Unlink a Grafana library panel - -Unlink a library panel when you want to make a change to the panel and not affect other instances of the library panel. - -## Before you begin - -- Identify the panel you want to unlink. - -**To unlink a library panel from a dashboard**: - -1. Hover over **Dashboard** on the left menu, and then click **Library panels**. -1. Select a library panel that is being used in different dashboards. -1. Select the panel you want to unlink. -1. Click the title of the panel and then click **Edit**. The panel opens in edit mode. -1. Click the **Unlink** option on the top right corner of the page. diff --git a/docs/sources/panels/override-field-values/add-a-field-override.md b/docs/sources/panels/override-field-values/add-a-field-override.md index aa64da1546d..c89a37e302e 100644 --- a/docs/sources/panels/override-field-values/add-a-field-override.md +++ b/docs/sources/panels/override-field-values/add-a-field-override.md @@ -10,12 +10,6 @@ weight: 30 You can override a field when you want to change the display of the value in the visualization. -## Before you begin - -- [Add a panel to a dashboard]({{< relref "../working-with-panels/add-panel/" >}}). - -**To add a field override**: - 1. Edit the panel to which you want to add an override. 1. In the panel display options, in the **Overrides** section, click **Add field override**. diff --git a/docs/sources/panels/override-field-values/delete-a-field-override.md b/docs/sources/panels/override-field-values/delete-a-field-override.md index 3557932847f..887a09a7b2f 100644 --- a/docs/sources/panels/override-field-values/delete-a-field-override.md +++ b/docs/sources/panels/override-field-values/delete-a-field-override.md @@ -12,13 +12,6 @@ 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. -## Before you begin - -- [Add a panel to a dashboard]({{< relref "../working-with-panels/add-panel/" >}}). -- [Add a field override]({{< relref "add-a-field-override/" >}}). - -**To delete a field override**: - 1. Edit the panel that contains the override you want to delete. 1. In panel display options, click the **Overrides** tab. 1. Click the override you want to delete and then click the associated trash icon. diff --git a/docs/sources/panels/override-field-values/edit-field-override.md b/docs/sources/panels/override-field-values/edit-field-override.md index 87299454e9b..64c3ea0b620 100644 --- a/docs/sources/panels/override-field-values/edit-field-override.md +++ b/docs/sources/panels/override-field-values/edit-field-override.md @@ -10,11 +10,6 @@ weight: 40 Edit a field override when you want to make changes to an override setting. -## Before you begin - -- [Add a panel to a dashboard]({{< relref "../working-with-panels/add-panel/" >}}). -- [Add a field override]({{< relref "add-a-field-override/" >}}). - **To edit a field override**: 1. Edit the panel that contains the overrides you want to edit. diff --git a/docs/sources/panels/override-field-values/view-field-override.md b/docs/sources/panels/override-field-values/view-field-override.md index 6c8de2c741f..2fbe8f609a0 100644 --- a/docs/sources/panels/override-field-values/view-field-override.md +++ b/docs/sources/panels/override-field-values/view-field-override.md @@ -10,11 +10,6 @@ weight: 20 You can view field overrides in the panel display options. -## Before you begin - -- [Add a panel to a dashboard]({{< relref "../working-with-panels/add-panel/" >}}). -- [Add a field override]({{< relref "add-a-field-override/" >}}). - **To view field overrides**: 1. Open for edit the panel that contains the overrides you want to view. diff --git a/docs/sources/panels/query-a-data-source/add-a-query.md b/docs/sources/panels/query-a-data-source/add-a-query.md index 6fea5855c44..61eb69897bf 100644 --- a/docs/sources/panels/query-a-data-source/add-a-query.md +++ b/docs/sources/panels/query-a-data-source/add-a-query.md @@ -14,7 +14,6 @@ A query returns data that Grafana visualizes in dashboards. When you create a pa - [Add a data source](../../../datasources/add-a-data-source). - Ensure that you know the query language of the data source. -- [Add a panel]({{< relref "../working-with-panels/add-panel/" >}}). **To add a query**: diff --git a/docs/sources/panels/query-a-data-source/download-raw-query-results.md b/docs/sources/panels/query-a-data-source/download-raw-query-results.md index 6205539ae03..f607a1cf915 100644 --- a/docs/sources/panels/query-a-data-source/download-raw-query-results.md +++ b/docs/sources/panels/query-a-data-source/download-raw-query-results.md @@ -10,13 +10,6 @@ weight: 70 Grafana generates a CSV file that contains your data, including any transformations to that data. You can choose to view the data before or after the panel applies field options or field option overrides. -## Before you begin - -- [Add a panel to a dashboard]({{< relref "../working-with-panels/add-panel/" >}}). -- [Add a query]({{< relref "add-a-query/" >}}). - -**To download raw query results**: - 1. Edit the panel that contains the query data you want to download. 1. In the query editor, click **Query Inspector**. 1. Click **Data**. diff --git a/docs/sources/panels/query-a-data-source/inspect-query-performance.md b/docs/sources/panels/query-a-data-source/inspect-query-performance.md index 367b956254c..dede421aab6 100644 --- a/docs/sources/panels/query-a-data-source/inspect-query-performance.md +++ b/docs/sources/panels/query-a-data-source/inspect-query-performance.md @@ -10,13 +10,6 @@ weight: 80 The **Stats** tab displays statistics that tell you how long your query takes, how many queries you send, and the number of rows returned. This information can help you troubleshoot your queries, especially if any of the numbers are unexpectedly high or low. -## Before you begin - -- [Add a panel to a dashboard]({{< relref "../working-with-panels/add-panel/" >}}). -- [Add a query]({{< relref "add-a-query/" >}}). - -**To inspect query performance**: - 1. Edit the panel that contains the query with performance you want to inspect. 1. In the query editor, click **Query Inspector**. 1. Click **Stats**. diff --git a/docs/sources/panels/query-a-data-source/inspect-request-and-response-data.md b/docs/sources/panels/query-a-data-source/inspect-request-and-response-data.md index 9b241cd61ae..2214db2fc3d 100644 --- a/docs/sources/panels/query-a-data-source/inspect-request-and-response-data.md +++ b/docs/sources/panels/query-a-data-source/inspect-request-and-response-data.md @@ -10,13 +10,6 @@ weight: 90 Inspect query request and response data when you want to troubleshoot a query that returns unexpected results, or fails to return expected results. -## Before you begin - -- [Add a panel to a dashboard]({{< relref "../working-with-panels/add-panel/" >}}). -- [Add a query]({{< relref "add-a-query/" >}}). - -**To inspect query request and response data**: - 1. Edit the panel that contains the query you want to export. 1. In the query editor, click **Query Inspector**. 1. Click **Refresh**. diff --git a/docs/sources/panels/query-a-data-source/share-query.md b/docs/sources/panels/query-a-data-source/share-query.md index 13ebda3724e..fcd3caff58a 100644 --- a/docs/sources/panels/query-a-data-source/share-query.md +++ b/docs/sources/panels/query-a-data-source/share-query.md @@ -14,9 +14,7 @@ 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. -**To share data source queries with another panel**: - -1. [Add a panel to a dashboard]({{< relref "../working-with-panels/add-panel/" >}}). +1. [Create a dashboard and add a panel]({{< relref "../../dashboards/add-organize-panels/#create-a-dashboard-and-add-a-panel" >}}). 1. Change the title to "Source panel". You'll use this panel as a source for the other panels. 1. Define the [query]({{< relref "add-a-query/" >}}) or queries that you want share. diff --git a/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/write-an-expression.md b/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/write-an-expression.md index eec926894e4..42eb4d76b1f 100644 --- a/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/write-an-expression.md +++ b/docs/sources/panels/query-a-data-source/use-expressions-to-manipulate-data/write-an-expression.md @@ -12,12 +12,6 @@ If your data source supports them, then Grafana displays the **Expression** butt For more information about expressions, refer to [About expressions]({{< relref "about-expressions/" >}}). -## Before you begin - -- [Add a panel]({{< relref "../../working-with-panels/add-panel/" >}}). - -**To write an expression**: - 1. Open the panel. 1. Below the query, click **Expression**. 1. In the **Operation** field, select the type of expression you want to write. diff --git a/docs/sources/panels/working-with-panels/add-panel.md b/docs/sources/panels/working-with-panels/add-panel.md deleted file mode 100644 index 5359473e3b4..00000000000 --- a/docs/sources/panels/working-with-panels/add-panel.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -aliases: - - /docs/grafana/latest/panels/working-with-panels/add-panel/ - - /docs/sources/panels/working-with-panels/add-panel/ -title: Add a panel to a dashboard -weight: 20 ---- - -# Add a panel to a dashboard - -Panels allow you to show your data in visual form. Each panel needs at least one query to display a visualization. - -## Before you begin - -- Ensure that you have the proper permissions. For more information about permissions, refer to [About users and permissions]({{< relref "../../administration/manage-users-and-permissions/about-users-and-permissions/" >}}). -- Identify the dashboard to which you want to add the panel. -- Understand the query language of the target data source. -- Ensure that data source for which you are writing a query has been added. For more information about adding a data source, refer to [Add a data source]({{< relref "../../datasources/add-a-data-source/" >}}) if you need instructions. - -**To add a panel to a dashboard**: - -1. Navigate to the dashboard to which you want to add a panel. -1. Click the **Add panel** icon. - - ![](/static/img/docs/panels/add-panel-icon-7-0.png) - -1. Click **Add an empty panel**. - - Grafana creates an empty time-series panel and selects the default data source. - -1. In the first line of the **Query** tab, click the drop-down list and select a data source. - -1. Write or construct a query in the query language of your data source. - - For more information about data sources, refer to [Data sources]({{< relref "../../datasources/" >}}) for specific guidelines. - -1. In the Visualization list, select a visualization type. - - Grafana displays a preview of your query results with the visualization applied. - - ![](/static/img/docs/panel-editor/select-visualization-8-0.png) - - For more information about individual visualizations, refer to [Visualizations options]({{< relref "../../visualizations/" >}}). - -1. Refer to the following documentation for ways you adjust panel settings. - - While not required, most visualizations need some adjustment before they properly display the information that you need. - - - [Format data using value mapping]({{< relref "../format-data/about-value-mapping/" >}}) - - [Visualization-specific options]({{< relref "../../visualizations/" >}}) - - [Override field values]({{< relref "../override-field-values/about-field-overrides/" >}}) - - [Configure thresholds]({{< relref "../configure-thresholds/" >}}) - - [Apply color to series and fields]({{< relref "apply-color-to-series/" >}}) - -1. Add a note to describe the visualization (or describe your changes) and then click **Save** in the upper-right corner of the page. - - Notes can be helpful if you need to revert the dashboard to a previous version. diff --git a/docs/sources/panels/working-with-panels/add-title-and-description.md b/docs/sources/panels/working-with-panels/add-title-and-description.md index f6ca5e9f76d..e08e2010362 100644 --- a/docs/sources/panels/working-with-panels/add-title-and-description.md +++ b/docs/sources/panels/working-with-panels/add-title-and-description.md @@ -10,12 +10,6 @@ weight: 30 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. -## Before you begin: - -- [Add a panel to a dashboard]({{< relref "add-panel/" >}}). - -**To add a title and description to a panel**: - 1. Open a panel. 1. In the panel display options pane, locate the **Panel options** section. diff --git a/docs/sources/panels/working-with-panels/apply-color-to-series.md b/docs/sources/panels/working-with-panels/apply-color-to-series.md index ba03bd60105..26e30345905 100644 --- a/docs/sources/panels/working-with-panels/apply-color-to-series.md +++ b/docs/sources/panels/working-with-panels/apply-color-to-series.md @@ -13,12 +13,6 @@ In addition to specifying color based on thresholds, you can configure the color 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. -## Before you begin - -- [Add a panel to a dashboard]({{< relref "add-panel/" >}}). - -**To apply color to series and fields**: - 1. In panel display options, scroll to the **Standard options** or **override** section. 1. Click the **Standard options Color scheme** drop-down, and select one of the following palettes: diff --git a/docs/sources/panels/working-with-panels/configure-legend.md b/docs/sources/panels/working-with-panels/configure-legend.md index d34df9f4f81..008bd996556 100644 --- a/docs/sources/panels/working-with-panels/configure-legend.md +++ b/docs/sources/panels/working-with-panels/configure-legend.md @@ -16,12 +16,6 @@ Visualizations can often be visually complex, and include many data series. You When you apply your changes, the visualization changes appear to all users of the panel. -### Before you begin - -- [Add a panel to a dashboard]({{< relref "add-panel/" >}}). - -**To isolate series data in a visualization**: - 1. Open the panel. 1. In the legend, click the label of the series you want to isolate. diff --git a/docs/sources/panels/working-with-panels/format-standard-fields.md b/docs/sources/panels/working-with-panels/format-standard-fields.md index 6d83f8d9857..ee0404ac817 100644 --- a/docs/sources/panels/working-with-panels/format-standard-fields.md +++ b/docs/sources/panels/working-with-panels/format-standard-fields.md @@ -16,12 +16,6 @@ For a complete list of field formatting options, refer to [Standard field defini > You can apply standard options to most built-in Grafana panels. Some older panels and community panels that have not updated to the new panel and data model will be missing either all or some of these field options. -## Before you begin - -- [Add a panel to a dashboard]({{< relref "add-panel/" >}}). - -**To format a standard field**: - 1. Open a dashboard, click the panel title, and click **Edit**. 1. In the panel display options pane, locate the **Standard options** section. diff --git a/docs/sources/panels/working-with-panels/navigate-panel-editor.md b/docs/sources/panels/working-with-panels/navigate-panel-editor.md deleted file mode 100644 index 4fe5faadee2..00000000000 --- a/docs/sources/panels/working-with-panels/navigate-panel-editor.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -aliases: - - /docs/grafana/latest/panels/working-with-panels/navigate-panel-editor/ - - /docs/sources/panels/working-with-panels/navigate-panel-editor/ -title: Navigate the Grafana panel editor -weight: 10 ---- - -# Navigate the Grafana panel editor - -This page describes the parts of the Grafana panel editor. - -1. Panel header: The header section lists the dashboard in which the panel appears and the following controls: - - - **Dashboard settings (gear) icon -** Click to access the dashboard settings. - - **Discard -** Discards changes you have made to the panel since you last saved the dashboard. - - **Save -** Saves changes you made to the panel. - - **Apply -** Applies changes you made and closes the panel editor, returning you to the dashboard. You will have to save the dashboard to persist the applied changes. - -2. Visualization preview: The visualization preview section contains the following options: - - - **Table view -** Convert any visualization to a table so that you can see the data. Table views are useful for troubleshooting. - - **Fill -** The visualization preview fills the available space. If you change the width of the side pane or height of the bottom pane the visualization changes to fill the available space. - - **Actual -** The visualization preview will have the exact size as the size on the dashboard. If not enough space is available, the visualization will scale down preserving the aspect ratio. - - **Time range controls -** For more information, refer to [Time range controls]({{< relref "../../dashboards/time-range-controls/" >}}). - -3. Data section: The data section contains tabs where you enter queries, transform your data, and create alert rules (if applicable). - - - **Query tab -** Select your data source and enter queries here. For more information, refer to [Add a query]({{< relref "../query-a-data-source/add-a-query/" >}}). - - - **Transform tab -** Apply data transformations. For more information, refer to [Transform data]({{< relref "../transform-data/" >}}). - - **Alert tab -** Write alert rules. For more information, refer to [Overview of Grafana 8 alerting]({{< relref "../../alerting/" >}}). - -4. Panel display options: The display options section contains tabs where you configure almost every aspect of your data visualization, including: - - - [Apply color to series and fields]({{< relref "apply-color-to-series/" >}}) - - [Format a standard field]({{< relref "format-standard-fields/" >}}) - - [Add a title and description to a panel]({{< relref "add-title-and-description/" >}}) - -> Not all options are available for each visualization. - -{{< figure src="/static/img/docs/panel-editor/panel-editor-8-0.png" class="docs-image--no-shadow" max-width="1500px" >}} diff --git a/docs/sources/panels/working-with-panels/view-json-model.md b/docs/sources/panels/working-with-panels/view-json-model.md index be52a641d5b..9220c684720 100644 --- a/docs/sources/panels/working-with-panels/view-json-model.md +++ b/docs/sources/panels/working-with-panels/view-json-model.md @@ -10,12 +10,6 @@ weight: 100 Explore and export panel, panel data, and data frame JSON models. -## Before you begin: - -- [Add a panel to a dashboard]({{< relref "add-panel/" >}}). - -**To view a panel JSON model**: - 1. Open the panel inspector and then click the **JSON** tab or in the panel menu click **Inspect > Panel JSON**. 1. In Select source, choose one of the following options: diff --git a/docs/sources/visualizations/_index.md b/docs/sources/visualizations/_index.md index 8a1f0bb7444..c44f3cfb368 100644 --- a/docs/sources/visualizations/_index.md +++ b/docs/sources/visualizations/_index.md @@ -10,7 +10,7 @@ weight: 75 Grafana offers a variety of visualizations to support different use cases. This section of the documentation highlights the built-in panels, their options and typical usage. -> **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. For more information, see the [add a panel]({{< relref "../panels/working-with-panels/add-panel/" >}}) documentation. +> **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. - Graphs & charts - [Time series]({{< relref "time-series/" >}}) is the default and main Graph visualization. diff --git a/docs/sources/visualizations/time-series/graph-time-series-as-bars.md b/docs/sources/visualizations/time-series/graph-time-series-as-bars.md index 338c26dea6d..aeb0ea82af0 100644 --- a/docs/sources/visualizations/time-series/graph-time-series-as-bars.md +++ b/docs/sources/visualizations/time-series/graph-time-series-as-bars.md @@ -18,9 +18,7 @@ This section explains how to use Time series field options to visualize time ser For more information about the time series visualization, refer to [Time series]({{< relref "_index.md" >}}). -## Create the panel - -1. [Add a panel]({{< relref "../../panels/working-with-panels/add-panel/" >}}). +1. [Create a dashboard and add a panel]({{< relref "../../dashboards/add-organize-panels/#create-a-dashboard-and-add-a-panel" >}}). 1. Select the **Time series** visualization. 1. In the Panel editor side pane, click **Graph styles** to expand it. 1. In Style, click **Bars**. diff --git a/docs/sources/visualizations/time-series/graph-time-series-as-lines.md b/docs/sources/visualizations/time-series/graph-time-series-as-lines.md index df3ff48e5a6..d35f40b3bb2 100644 --- a/docs/sources/visualizations/time-series/graph-time-series-as-lines.md +++ b/docs/sources/visualizations/time-series/graph-time-series-as-lines.md @@ -16,9 +16,8 @@ weight: 200 This section explains how to use Time series field options to visualize time series data as lines and illustrates what the options do. -## Create the panel - -1. [Add a panel]({{< relref "../../panels/working-with-panels/add-panel/" >}}). Select the [Time series]({{< relref "_index.md" >}}) visualization. +1. [Create a dashboard and add a panel]({{< relref "../../dashboards/add-organize-panels/#create-a-dashboard-and-add-a-panel" >}}). +1. Select the [Time series]({{< relref "_index.md" >}}) visualization. 1. In the Panel editor side pane, click **Graph styles** to expand it. 1. In Style, click **Lines**. diff --git a/docs/sources/visualizations/time-series/graph-time-series-as-points.md b/docs/sources/visualizations/time-series/graph-time-series-as-points.md index c59bc1f84bd..a3ecd032ada 100644 --- a/docs/sources/visualizations/time-series/graph-time-series-as-points.md +++ b/docs/sources/visualizations/time-series/graph-time-series-as-points.md @@ -16,9 +16,8 @@ weight: 300 This section explains how to use Time series field options to visualize time series data as points and illustrates what the options do. -## Create the panel - -1. [Add a panel]({{< relref "../../panels/working-with-panels/add-panel/" >}}). Select the [Time series]({{< relref "_index.md" >}}) visualization. +1. [Create a dashboard and add a panel]({{< relref "../../dashboards/add-organize-panels/#create-a-dashboard-and-add-a-panel" >}}). +1. Select the [Time series]({{< relref "_index.md" >}}) visualization. 1. In the Panel editor side pane, click **Graph styles** to expand it. 1. In Style, click **Points**. diff --git a/docs/sources/whatsnew/whats-new-in-v7-5.md b/docs/sources/whatsnew/whats-new-in-v7-5.md index 21c1aa783f8..0e07db2ee76 100644 --- a/docs/sources/whatsnew/whats-new-in-v7-5.md +++ b/docs/sources/whatsnew/whats-new-in-v7-5.md @@ -15,7 +15,7 @@ title: What's new in Grafana v7.5 weight: -32 --- -# What’s new in Grafana v7.5 +# 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). diff --git a/docs/sources/whatsnew/whats-new-in-v8-0.md b/docs/sources/whatsnew/whats-new-in-v8-0.md index 1ab026e6fb7..41cbc4d84e8 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-0.md +++ b/docs/sources/whatsnew/whats-new-in-v8-0.md @@ -15,7 +15,7 @@ title: What's new in Grafana v8.0 weight: -33 --- -# What’s new in Grafana v8.0 +# 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). diff --git a/docs/sources/whatsnew/whats-new-in-v8-1.md b/docs/sources/whatsnew/whats-new-in-v8-1.md index 3e7327dbd33..3c8ba92d9f7 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-1.md +++ b/docs/sources/whatsnew/whats-new-in-v8-1.md @@ -15,7 +15,7 @@ title: What's new in Grafana v8.1 weight: -33 --- -# What’s new in Grafana v8.1 +# What's new in Grafana v8.1 > **Note:** This topic will be updated frequently between now and the final release. diff --git a/docs/sources/whatsnew/whats-new-in-v8-2.md b/docs/sources/whatsnew/whats-new-in-v8-2.md index 5717519ff30..c5f06327920 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-2.md +++ b/docs/sources/whatsnew/whats-new-in-v8-2.md @@ -15,7 +15,7 @@ title: What's new in Grafana v8.2 weight: -33 --- -# What’s new in Grafana v8.2 +# What's new in Grafana v8.2 Grafana 8.2 continues to build on the foundation of Grafana 8.0 & 8.1. Grafana 8.2 also marks the start of our work to bring Grafana closer to all users with a focus on increasing Grafana’s accessibility, part of its continuing mission to democratize metrics _for everyone_. diff --git a/docs/sources/whatsnew/whats-new-in-v8-3.md b/docs/sources/whatsnew/whats-new-in-v8-3.md index 31e86ab89b5..c609c24c4f0 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-3.md +++ b/docs/sources/whatsnew/whats-new-in-v8-3.md @@ -15,7 +15,7 @@ title: What's new in Grafana v8.3 weight: -33 --- -# What’s new in Grafana v8.3 +# What's new in Grafana v8.3 Grafana 8.3 is an exciting release for Grafana Labs. This release includes the new Candlestick Panel, a new visualization suggestions engine and, for enterprise users, Recorded Queries. diff --git a/docs/sources/whatsnew/whats-new-in-v8-4.md b/docs/sources/whatsnew/whats-new-in-v8-4.md index 45ba5fdbb4c..a1de0eef169 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-4.md +++ b/docs/sources/whatsnew/whats-new-in-v8-4.md @@ -15,7 +15,7 @@ title: What's new in Grafana v8.4 weight: -33 --- -# What’s new in Grafana v8.4 +# What's new in Grafana v8.4 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. diff --git a/docs/sources/whatsnew/whats-new-in-v8-5.md b/docs/sources/whatsnew/whats-new-in-v8-5.md index 96b19a7deac..1d7fefd60f9 100644 --- a/docs/sources/whatsnew/whats-new-in-v8-5.md +++ b/docs/sources/whatsnew/whats-new-in-v8-5.md @@ -15,7 +15,7 @@ title: What's new in Grafana v8.5 weight: -33 --- -# What’s new in Grafana v8.5 +# What's new in Grafana v8.5 We’re excited to announce Grafana v8.5, with a variety of improvements that focus on Grafana’s usability, performance, and security. diff --git a/docs/sources/whatsnew/whats-new-in-v9-0.md b/docs/sources/whatsnew/whats-new-in-v9-0.md index 2b6a8a98dba..74b1017bdc7 100644 --- a/docs/sources/whatsnew/whats-new-in-v9-0.md +++ b/docs/sources/whatsnew/whats-new-in-v9-0.md @@ -15,7 +15,7 @@ title: What's new in Grafana v9.0 weight: -33 --- -# What’s new in Grafana v9.0 +# What's new in Grafana v9.0 As tradition goes, GrafanaCon — our yearly community event for Grafana open source users — is also where we launch the latest software release of Grafana. Keeping up with tradition, we are excited to be announcing Grafana v9.0: a release that elevates Grafana’s ease of use, discovery of data through new and improved visualizations and a default Grafana Alerting experience. diff --git a/go.mod b/go.mod index efe22d55747..78270aecc63 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/fatih/color v1.13.0 github.com/gchaincl/sqlhooks v1.3.0 github.com/getsentry/sentry-go v0.13.0 + github.com/go-git/go-git/v5 v5.4.2 github.com/go-kit/kit v0.11.0 github.com/go-openapi/strfmt v0.20.2 github.com/go-redis/redis/v8 v8.11.4 @@ -213,7 +214,7 @@ require ( github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect github.com/segmentio/encoding v0.3.2 github.com/sercand/kuberesolver v2.4.0+incompatible // indirect - github.com/sergi/go-diff v1.0.0 // indirect + github.com/sergi/go-diff v1.1.0 // indirect github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 // indirect github.com/sirupsen/logrus v1.8.1 // indirect @@ -273,7 +274,9 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.2.1 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0 // indirect github.com/Microsoft/go-winio v0.5.2 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 // indirect github.com/RoaringBitmap/roaring v0.9.4 // indirect + github.com/acomagu/bufpipe v1.0.3 // indirect github.com/axiomhq/hyperloglog v0.0.0-20191112132149-a4c4c47bc57f // indirect github.com/bits-and-blooms/bitset v1.2.0 // indirect github.com/blevesearch/go-porterstemmer v1.0.3 // indirect @@ -288,17 +291,23 @@ require ( github.com/coreos/go-semver v0.3.0 // indirect github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc // indirect github.com/elazarl/goproxy v0.0.0-20220115173737-adb46da277ac // indirect + github.com/emirpasic/gods v1.12.0 // indirect github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 // indirect + github.com/go-git/gcfg v1.5.0 // indirect + github.com/go-git/go-billy/v5 v5.3.1 // indirect github.com/go-logr/logr v1.2.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0 // indirect github.com/imdario/mergo v0.3.12 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 // indirect github.com/klauspost/compress v1.15.2 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/labstack/echo/v4 v4.7.2 // indirect github.com/labstack/gommon v0.3.1 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mschoch/smat v0.2.0 // indirect github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799 // indirect @@ -307,11 +316,13 @@ require ( github.com/smartystreets/goconvey v1.7.2 // indirect github.com/valyala/fasttemplate v1.2.1 // indirect github.com/wk8/go-ordered-map v1.0.0 + github.com/xanzy/ssh-agent v0.3.0 // indirect github.com/xlab/treeprint v1.1.0 // indirect github.com/yudai/pp v2.0.1+incompatible // indirect go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.6.3 // indirect go.opentelemetry.io/proto/otlp v0.15.0 // indirect golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect k8s.io/api v0.22.5 // indirect k8s.io/apimachinery v0.22.5 // indirect k8s.io/klog/v2 v2.30.0 // indirect diff --git a/go.sum b/go.sum index 1bb9a7c48e9..df4a323117f 100644 --- a/go.sum +++ b/go.sum @@ -254,6 +254,8 @@ 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/OneOfOne/xxhash v1.2.5/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= github.com/OneOfOne/xxhash v1.2.6/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= +github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7 h1:YoJbenK9C67SkzkDfmQuVln04ygHj3vjZfd9FL+GmQQ= +github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= @@ -275,6 +277,8 @@ github.com/StackExchange/wmi v0.0.0-20210224194228-fe8f1750fd46/go.mod h1:3eOhrU github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f h1:HR5nRmUQgXrwqZOwZ2DAc/aCi3Bu3xENpspW935vxu0= github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f/go.mod h1:f3HiCrHjHBdcm6E83vGaXh1KomZMA2P6aeo3hKx/wg0= +github.com/acomagu/bufpipe v1.0.3 h1:fxAGrHZTgQ9w5QqVItgzwj235/uYZYgbXitB+dLupOk= +github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= @@ -294,6 +298,8 @@ github.com/aliyun/aliyun-oss-go-sdk v2.0.4+incompatible/go.mod h1:T/Aws4fEfogEE9 github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.0.3 h1:fpcw+r1N1h0Poc1F/pHbW40cUm/lMEQslZtCkBQ0UnM= github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= github.com/antihax/optional v0.0.0-20180407024304-ca021399b1a6/go.mod h1:V8iCPQYkqmusNa815XgQio277wI47sdRh1dUOLdyC6Q= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antonmedv/expr v1.8.9/go.mod h1:5qsM3oLGDND7sDmQGDXHkYfkjYMUX14qsgqmHhwGEk8= @@ -316,6 +322,7 @@ github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb 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= @@ -783,6 +790,8 @@ github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT github.com/emicklei/proto v1.6.15/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= github.com/emicklei/proto v1.10.0 h1:pDGyFRVV5RvV+nkBK9iy3q67FBy9Xa7vwrOTE+g5aGw= github.com/emicklei/proto v1.10.0/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= +github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= +github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -816,6 +825,7 @@ github.com/felixge/fgprof v0.9.1/go.mod h1:7/HK6JFtFaARhIljgP2IV8rJLIoHDoOYoUphs github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.2 h1:+nS9g82KMXccJ/wp0zyRW9ZBHFETmMGtkk+2CTTrW4o= github.com/felixge/httpsnoop v1.0.2/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= 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= @@ -851,6 +861,8 @@ github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32 h1:Mn26/9ZMNWSw9C9ER github.com/ghodss/yaml v1.0.1-0.20190212211648-25d852aebe32/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.7.7/go.mod h1:axIBovoeJpVj8S3BwE0uPMTeReE4+AfFtqpqaZ1qq1U= +github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= +github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= 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/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= @@ -863,6 +875,15 @@ github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0 github.com/go-fonts/latin-modern v0.2.0/go.mod h1:rQVLdDMK+mK1xscDwsqM5J8U2jrRa3T0ecnM9pNujks= github.com/go-fonts/liberation v0.1.1/go.mod h1:K6qoJYypsmfVjWg8KOVDQhLc8UDgIK2HYqyqAO9z7GY= github.com/go-fonts/stix v0.1.0/go.mod h1:w/c1f0ldAUlJmLBvlbkvVXLAD+tAMqobIIQpmnUIzUY= +github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= +github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= +github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Aiu34= +github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8= +github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0= +github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4= +github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= 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= @@ -1557,6 +1578,8 @@ github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0f 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/jaegertracing/jaeger v1.24.0/go.mod h1:mqdtFDA447va5j0UewDaAWyNlGreGQyhGxXVhbF58gQ= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 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.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o= @@ -1618,6 +1641,8 @@ github.com/jwilder/encoding v0.0.0-20170811194829-b4e1701a28ef/go.mod h1:Ct9fl0F github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= 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/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351 h1:DowS9hvgyYSX4TO5NpyC606/Z4SxnNYbT+WX27or6Ck= +github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 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= @@ -1729,6 +1754,7 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho= +github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= @@ -2226,8 +2252,9 @@ github.com/segmentio/kafka-go v0.2.0/go.mod h1:X6itGqS9L4jDletMsxZ7Dz+JFWxM6JHfP github.com/sercand/kuberesolver v2.1.0+incompatible/go.mod h1:lWF3GL0xptCB/vCiJPl/ZshwPsX/n4Y7u0CW9E7aQIQ= github.com/sercand/kuberesolver v2.4.0+incompatible h1:WE2OlRf6wjLxHwNkkFLQGaZcVLEXjMjBPjjEU5vksH8= github.com/sercand/kuberesolver v2.4.0+incompatible/go.mod h1:lWF3GL0xptCB/vCiJPl/ZshwPsX/n4Y7u0CW9E7aQIQ= -github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shirou/gopsutil v3.21.6+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= @@ -2426,6 +2453,8 @@ github.com/willf/bitset v1.1.11/go.mod h1:83CECat5yLh5zVOf4P1ErAgKA5UDvKtgyUABdr 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/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= +github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI= +github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= 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= github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= @@ -2609,6 +2638,7 @@ golang.org/x/crypto v0.0.0-20181009213950-7c1a557ab941/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190211182817-74369b46fc67/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/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= @@ -2644,6 +2674,7 @@ golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210314154223-e6e6c4f2bb5b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -2785,6 +2816,7 @@ golang.org/x/net v0.0.0-20210224082022-3d97a244fca7/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210324051636-2c4c8ecb7826/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= @@ -2984,6 +3016,7 @@ golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210503080704-8803ae5d1324/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210503173754-0981d6026fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -3459,6 +3492,7 @@ gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= 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/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 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= diff --git a/package.json b/package.json index 78e0665c3c1..17cfbda6d88 100644 --- a/package.json +++ b/package.json @@ -218,7 +218,6 @@ "mutationobserver-shim": "0.3.7", "ngtemplate-loader": "2.1.0", "node-notifier": "10.0.1", - "nodemon": "2.0.16", "postcss": "8.4.14", "postcss-loader": "7.0.0", "postcss-reporter": "7.0.5", diff --git a/packages/grafana-data/src/transformations/transformers/histogram.ts b/packages/grafana-data/src/transformations/transformers/histogram.ts index 9dc72bcfd31..58bc951d2e2 100644 --- a/packages/grafana-data/src/transformations/transformers/histogram.ts +++ b/packages/grafana-data/src/transformations/transformers/histogram.ts @@ -160,13 +160,13 @@ export function buildHistogram(frames: DataFrame[], options?: HistogramTransform for (const frame of frames) { for (const field of frame.fields) { if (field.type === FieldType.number) { - allValues = allValues.concat( - field.values.toArray().map((val: number) => Number(val.toFixed(field.config.decimals ?? 0))) - ); + allValues = allValues.concat(field.values.toArray()); } } } + allValues = allValues.filter((v) => v != null); + allValues.sort((a, b) => a - b); let smallestDelta = Infinity; @@ -204,6 +204,9 @@ export function buildHistogram(frames: DataFrame[], options?: HistogramTransform const getBucket = (v: number) => incrRoundDn(v - bucketOffset, bucketSize!) + bucketOffset; + // guess number of decimals + let bucketDecimals = (('' + bucketSize).match(/\.\d+$/) ?? ['.'])[0].length - 1; + let histograms: AlignedData[] = []; let counts: Field[] = []; let config: FieldConfig | undefined = undefined; @@ -220,7 +223,7 @@ export function buildHistogram(frames: DataFrame[], options?: HistogramTransform unit: undefined, }, }); - if (!config && Object.keys(field.config).length) { + if (!config && field.config.unit) { config = field.config; } } @@ -251,7 +254,13 @@ export function buildHistogram(frames: DataFrame[], options?: HistogramTransform values: new ArrayVector(joinedHists[0]), type: FieldType.number, state: undefined, - config: config ?? {}, + config: + bucketDecimals === 0 + ? config ?? {} + : { + ...config, + decimals: bucketDecimals, + }, }; const bucketMax = { ...bucketMin, diff --git a/packages/grafana-data/src/types/datasource.ts b/packages/grafana-data/src/types/datasource.ts index 3b118ec2efb..1be984a4dbd 100644 --- a/packages/grafana-data/src/types/datasource.ts +++ b/packages/grafana-data/src/types/datasource.ts @@ -483,7 +483,6 @@ export interface DataQueryRequest { timeInfo?: string; // The query time description (blue text in the upper right) panelId?: number; dashboardId?: number; - // Temporary prop for public dashboards, to be replaced by publicAccessKey publicDashboardAccessToken?: string; // Request Timing diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index ccf3e461d71..d28102166c2 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -56,6 +56,7 @@ export interface FeatureToggles { autoMigrateGraphPanels?: boolean; prometheusWideSeries?: boolean; canvasPanelNesting?: boolean; + scenes?: boolean; useLegacyHeatmapPanel?: boolean; cloudMonitoringExperimentalUI?: boolean; logRequestsInstrumentedAsUnknown?: boolean; diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index 58f79261528..453e1a1f572 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -56,10 +56,6 @@ export interface NavModel { * This is the current active tab/navigation. */ node: NavModelItem; - /** - * Describes breadcrumbs that are used in places such as data source settings., folder page and plugins page. - */ - breadcrumbs?: NavModelItem[]; } export interface NavModelBreadcrumb { diff --git a/packages/grafana-runtime/src/components/PanelRenderer.tsx b/packages/grafana-runtime/src/components/PanelRenderer.tsx index 8c0619ba3ba..a16377052c8 100644 --- a/packages/grafana-runtime/src/components/PanelRenderer.tsx +++ b/packages/grafana-runtime/src/components/PanelRenderer.tsx @@ -11,7 +11,7 @@ import { AbsoluteTimeRange, FieldConfigSource, PanelData } from '@grafana/data'; * @internal */ export interface PanelRendererProps

{ - data: PanelData; + data?: PanelData; pluginId: string; title: string; options?: Partial

; diff --git a/packages/grafana-runtime/src/utils/PublicDashboardDataSource.test.ts b/packages/grafana-runtime/src/utils/PublicDashboardDataSource.test.ts index c86ee702e7c..f72460483f8 100644 --- a/packages/grafana-runtime/src/utils/PublicDashboardDataSource.test.ts +++ b/packages/grafana-runtime/src/utils/PublicDashboardDataSource.test.ts @@ -1,9 +1,14 @@ import { of } from 'rxjs'; import { BackendSrv, BackendSrvRequest } from 'src/services'; -import { DataQueryRequest, DataSourceRef } from '@grafana/data'; +import { DataQueryRequest, DataSourceInstanceSettings, DataSourceRef } from '@grafana/data'; -import { PublicDashboardDataSource } from '../../../../public/app/features/dashboard/services/PublicDashboardDataSource'; +import { + PUBLIC_DATASOURCE, + PublicDashboardDataSource, +} from '../../../../public/app/features/dashboard/services/PublicDashboardDataSource'; + +import { DataSourceWithBackend } from './DataSourceWithBackend'; const mockDatasourceRequest = jest.fn(); @@ -28,7 +33,7 @@ describe('PublicDashboardDatasource', () => { mockDatasourceRequest.mockReset(); mockDatasourceRequest.mockReturnValue(Promise.resolve({})); - const ds = new PublicDashboardDataSource(); + const ds = new PublicDashboardDataSource('public'); const panelId = 1; const publicDashboardAccessToken = 'abc123'; @@ -47,4 +52,27 @@ describe('PublicDashboardDatasource', () => { `/api/public/dashboards/${publicDashboardAccessToken}/panels/${panelId}/query` ); }); + + test('returns public datasource uid when datasource passed in is null', () => { + let ds = new PublicDashboardDataSource(null); + expect(ds.uid).toBe(PUBLIC_DATASOURCE); + }); + + test('returns datasource when datasource passed in is a string', () => { + let ds = new PublicDashboardDataSource('theDatasourceUid'); + expect(ds.uid).toBe('theDatasourceUid'); + }); + + test('returns datasource uid when datasource passed in is a DataSourceRef implementation', () => { + const datasource = { type: 'datasource', uid: 'abc123' }; + let ds = new PublicDashboardDataSource(datasource); + expect(ds.uid).toBe('abc123'); + }); + + test('returns datasource uid when datasource passed in is a DatasourceApi instance', () => { + const settings: DataSourceInstanceSettings = { id: 1, uid: 'abc123' } as DataSourceInstanceSettings; + const datasource = new DataSourceWithBackend(settings); + let ds = new PublicDashboardDataSource(datasource); + expect(ds.uid).toBe('abc123'); + }); }); diff --git a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh index e5120dc6eaa..b4aa39c685c 100755 --- a/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh +++ b/packages/grafana-toolkit/docker/grafana-plugin-ci-e2e/scripts/deploy.sh @@ -41,7 +41,7 @@ chmod 755 /usr/local/bin/golangci-lint # Install code climate get_file "https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64" \ "/usr/local/bin/cc-test-reporter" \ - "9fbe34cd207924d8f51ac0f3ffa690df4169e055cd7134a2370f6650f5b356e1" + "9e6c3e628b1258aefbc6e3550438a691abb0d1924d63c038881031d3d343c7d0" chmod 755 /usr/local/bin/cc-test-reporter wget -O /usr/local/bin/grabpl "https://grafana-downloads.storage.googleapis.com/grafana-build-pipeline/v2.9.27/grabpl" diff --git a/packages/grafana-ui/src/components/Button/Button.tsx b/packages/grafana-ui/src/components/Button/Button.tsx index 2479687518a..bce87b5a739 100644 --- a/packages/grafana-ui/src/components/Button/Button.tsx +++ b/packages/grafana-ui/src/components/Button/Button.tsx @@ -9,6 +9,7 @@ import { IconName } from '../../types/icon'; import { ComponentSize } from '../../types/size'; import { getPropertiesForButtonSize } from '../Forms/commonStyles'; import { Icon } from '../Icon/Icon'; +import { PopoverContent, Tooltip, TooltipPlacement } from '../Tooltip'; export type ButtonVariant = 'primary' | 'secondary' | 'destructive'; export const allButtonVariants: ButtonVariant[] = ['primary', 'secondary', 'destructive']; @@ -24,6 +25,10 @@ type CommonProps = { children?: React.ReactNode; fullWidth?: boolean; type?: string; + /** Tooltip content to display on hover */ + tooltip?: PopoverContent; + /** Position of the tooltip */ + tooltipPlacement?: TooltipPlacement; }; export type ButtonProps = CommonProps & ButtonHTMLAttributes; @@ -79,6 +84,8 @@ export const LinkButton = React.forwardRef( onBlur, onFocus, disabled, + tooltip, + tooltipPlacement, ...otherProps }, ref @@ -103,12 +110,22 @@ export const LinkButton = React.forwardRef( className ); - return ( + const button = ( {icon && } {children && {children}} ); + + if (tooltip) { + return ( + + {button} + + ); + } + + return button; } ); diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx index 9524e497cad..bf91200c338 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import React from 'react'; +import React, { CSSProperties } from 'react'; import { LinkModel } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -12,6 +12,7 @@ import { MenuItem } from '../Menu/MenuItem'; interface DataLinksContextMenuProps { children: (props: DataLinksContextMenuApi) => JSX.Element; links: () => LinkModel[]; + style?: CSSProperties; } export interface DataLinksContextMenuApi { @@ -19,7 +20,7 @@ export interface DataLinksContextMenuApi { targetClassName?: string; } -export const DataLinksContextMenu: React.FC = ({ children, links }) => { +export const DataLinksContextMenu: React.FC = ({ children, links, style }) => { const itemsGroup: MenuItemsGroup[] = [{ items: linkModelToContextMenuItems(links), label: 'Data links' }]; const linksCounter = itemsGroup[0].items.length; const renderMenuGroupItems = () => { @@ -61,7 +62,7 @@ export const DataLinksContextMenu: React.FC = ({ chil onClick={linkModel.onClick} target={linkModel.target} title={linkModel.title} - style={{ display: 'flex', width: '100%' }} + style={style} aria-label={selectors.components.DataLinksContextMenu.singleLink} > {children({})} diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx index b4985badef9..5accaec58c6 100644 --- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx +++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx @@ -118,7 +118,7 @@ export function UnthemedTimeRangePicker(props: TimeRangePickerProps): ReactEleme {isOpen && ( - +

+ +# Plugin Signature Badge + +The Plugin Signature Badge can be found in each information plugin card of the configuration plugins panel (Grafana menu > Configuration > Plugins). +This is a security measure to make sure plugins haven't been tampered with. Upon loading, Grafana checks to see the plugin signature status. + +## Badges: + +1.- Core: this badge indicates that the plugin has been built into Grafana. + + + + + +2.- Signed: the plugin's signature was successfully verified. + + + + + +3.- Invalid signature: this plugin has a invalid signature. + + + + + +4.- Modified signature: this plugin has changed since it was signed. This may indicate malicious intent. + + + + + +5.- Unsigned: the plugin is not signed. In this case, as upon loading Grafana checks the plugins signature, Grafana will not load or start it. Furthermore, it will write an error message to the server log. + + + + + + diff --git a/packages/grafana-ui/src/components/PluginSignatureBadge/PluginSignatureBadge.story.tsx b/packages/grafana-ui/src/components/PluginSignatureBadge/PluginSignatureBadge.story.tsx index 0f35ecf1404..ed1d7d46c7a 100644 --- a/packages/grafana-ui/src/components/PluginSignatureBadge/PluginSignatureBadge.story.tsx +++ b/packages/grafana-ui/src/components/PluginSignatureBadge/PluginSignatureBadge.story.tsx @@ -6,6 +6,8 @@ import { PluginSignatureBadge } from '@grafana/ui'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import mdx from './PluginSignatureBadge.mdx'; + export default { title: 'Data Display/PluginSignatureBadge', decorators: [withCenteredStory], @@ -24,6 +26,11 @@ export default { ], }, }, + parameters: { + docs: { + page: mdx, + }, + }, }; export const Basic: Story = (args) => { diff --git a/packages/grafana-ui/src/components/Table/BarGaugeCell.tsx b/packages/grafana-ui/src/components/Table/BarGaugeCell.tsx index 842a09ef47f..4e1c61f858e 100644 --- a/packages/grafana-ui/src/components/Table/BarGaugeCell.tsx +++ b/packages/grafana-ui/src/components/Table/BarGaugeCell.tsx @@ -50,7 +50,7 @@ export const BarGaugeCell: FC = (props) => { return field.getLinks({ valueRowIndex: row.index }); }; - const hasLinks = !!getLinks().length; + const hasLinks = Boolean(getLinks().length); const renderComponent = (menuProps: DataLinksContextMenuApi) => { const { openMenu, targetClassName } = menuProps; @@ -76,7 +76,11 @@ export const BarGaugeCell: FC = (props) => { return (
- {hasLinks && {(api) => renderComponent(api)}} + {hasLinks && ( + + {(api) => renderComponent(api)} + + )} {!hasLinks && ( = (props) => { const displayValue = field.display!(cell.value); - const hasLinks = getCellLinks(field, row)?.length; + const hasLinks = Boolean(getCellLinks(field, row)?.length); return (
diff --git a/packages/grafana-ui/src/components/Table/JSONViewCell.tsx b/packages/grafana-ui/src/components/Table/JSONViewCell.tsx index 029dd2abd2d..70c73fce64e 100644 --- a/packages/grafana-ui/src/components/Table/JSONViewCell.tsx +++ b/packages/grafana-ui/src/components/Table/JSONViewCell.tsx @@ -27,7 +27,7 @@ export function JSONViewCell(props: TableCellProps): JSX.Element { displayValue = JSON.stringify(value, null, ' '); } - const hasLinks = getCellLinks(field, row)?.length; + const hasLinks = Boolean(getCellLinks(field, row)?.length); return (
diff --git a/packages/grafana-ui/src/components/Table/styles.ts b/packages/grafana-ui/src/components/Table/styles.ts index 7bb9d5b5b21..bc7261316c2 100644 --- a/packages/grafana-ui/src/components/Table/styles.ts +++ b/packages/grafana-ui/src/components/Table/styles.ts @@ -165,7 +165,6 @@ export const getTableStyles = (theme: GrafanaTheme2) => { imageCellLink: css` cursor: pointer; overflow: hidden; - width: 100%; height: 100%; `, headerFilter: css` diff --git a/packages/grafana-ui/src/components/Tabs/VerticalTab.tsx b/packages/grafana-ui/src/components/Tabs/VerticalTab.tsx new file mode 100644 index 00000000000..c1fdeccff2b --- /dev/null +++ b/packages/grafana-ui/src/components/Tabs/VerticalTab.tsx @@ -0,0 +1,96 @@ +import { css, cx } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; + +import { useStyles2 } from '../../themes/ThemeContext'; +import { Icon } from '../Icon/Icon'; + +import { Counter } from './Counter'; +import { TabProps } from './Tab'; + +export const VerticalTab = React.forwardRef( + ({ label, active, icon, counter, className, suffix: Suffix, onChangeTab, href, ...otherProps }, ref) => { + const tabsStyles = useStyles2(getTabStyles); + const content = () => ( + <> + {icon && } + {label} + {typeof counter === 'number' && } + {Suffix && } + + ); + + const linkClass = cx(tabsStyles.link, active && tabsStyles.activeStyle); + + return ( +
  • + + {content()} + +
  • + ); + } +); + +VerticalTab.displayName = 'Tab'; + +const getTabStyles = (theme: GrafanaTheme2) => { + return { + item: css` + list-style: none; + margin-right: ${theme.spacing(2)}; + position: relative; + display: block; + margin-bottom: 4px; + `, + link: css` + padding: 6px 12px; + display: block; + height: 100%; + cursor: pointer; + + color: ${theme.colors.text.primary}; + + svg { + margin-right: ${theme.spacing(1)}; + } + + &:hover, + &:focus { + text-decoration: underline; + } + `, + activeStyle: css` + label: activeTabStyle; + color: ${theme.colors.text.maxContrast}; + font-weight: 500; + overflow: hidden; + + &::before { + display: block; + content: ' '; + position: absolute; + left: 0; + width: 4px; + bottom: 0; + top: 0; + border-radius: 2px; + background-image: linear-gradient(0deg, #f05a28 30%, #fbca0a 99%); + } + `, + suffix: css` + margin-left: ${theme.spacing(1)}; + `, + }; +}; diff --git a/packages/grafana-ui/src/components/Tags/Tag.mdx b/packages/grafana-ui/src/components/Tags/Tag.mdx index d7a1dd79371..a635ef2c88f 100644 --- a/packages/grafana-ui/src/components/Tags/Tag.mdx +++ b/packages/grafana-ui/src/components/Tags/Tag.mdx @@ -1,6 +1,8 @@ import { Story, Preview, Props } from '@storybook/addon-docs/blocks'; import { Tag } from './Tag'; + + # Tag Used for displaying metadata, for example to add more details to search results. Background and border colors are generated from the tag name. diff --git a/packages/grafana-ui/src/components/Tags/Tag.story.tsx b/packages/grafana-ui/src/components/Tags/Tag.story.tsx index 07222066547..5ddda1be408 100644 --- a/packages/grafana-ui/src/components/Tags/Tag.story.tsx +++ b/packages/grafana-ui/src/components/Tags/Tag.story.tsx @@ -1,10 +1,10 @@ import { action } from '@storybook/addon-actions'; +import { Story } from '@storybook/react'; import React from 'react'; -import { Tag } from '@grafana/ui'; - import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import { Props as TagProps, Tag } from './Tag'; import mdx from './Tag.mdx'; export default { @@ -15,9 +15,28 @@ export default { docs: { page: mdx, }, + controls: { + exclude: ['onClick'], + }, + }, + args: { + name: 'Tag', + colorIndex: 0, + showIcon: false, }, }; -export const single = () => { - return ; +interface StoryProps extends TagProps { + showIcon?: boolean; +} + +export const Single: Story = (args) => { + return ( + + ); }; diff --git a/packages/grafana-ui/src/components/Tags/Tag.tsx b/packages/grafana-ui/src/components/Tags/Tag.tsx index eee65fa0cd8..362fc04a26d 100644 --- a/packages/grafana-ui/src/components/Tags/Tag.tsx +++ b/packages/grafana-ui/src/components/Tags/Tag.tsx @@ -64,13 +64,15 @@ const getTagStyles = (theme: GrafanaTheme, name: string, colorIndex?: number) => font-weight: ${theme.typography.weight.semibold}; font-size: ${theme.typography.size.sm}; line-height: ${theme.typography.lineHeight.xs}; - vertical-align: baseline; background-color: ${colors.color}; color: ${theme.palette.gray98}; white-space: nowrap; text-shadow: none; padding: 3px 6px; border-radius: ${theme.border.radius.md}; + display: flex; + align-items: center; + gap: 3px; `, hover: css` &:hover { diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 721d749c863..e9dd8b85ada 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -79,6 +79,7 @@ export { TableCellDisplayMode, TableSortByFieldState } from './Table/types'; export { TableInputCSV } from './TableInputCSV/TableInputCSV'; export { TabsBar } from './Tabs/TabsBar'; export { Tab } from './Tabs/Tab'; +export { VerticalTab } from './Tabs/VerticalTab'; export { TabContent } from './Tabs/TabContent'; export { Counter } from './Tabs/Counter'; diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js index e584f83fcfe..f3768546bc7 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.test.js @@ -16,6 +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 SpanBarRow from './SpanBarRow'; describe('', () => { @@ -43,11 +45,10 @@ describe('', () => { showErrorIcon: false, getViewedBounds: () => ({ start: 0, end: 1 }), span: { - duration: 'test-duration', + duration: 9000, hasChildren: true, process: { serviceName: 'service-name', - tags: [], }, spanID, logs: [], @@ -181,4 +182,89 @@ describe('', () => { ); expect(screen.getAllByTestId('SpanLinksMenu')).toHaveLength(1); }); + + describe('render span bar label', () => { + it('with default value', () => { + render(); + expect(screen.getByText('(9ms)')).toBeInTheDocument(); + }); + + it('with none value', () => { + const testProps = Object.assign( + { + spanBarOptions: { + type: NONE, + }, + }, + props + ); + render(); + expect(screen.queryByText('(9ms)')).not.toBeInTheDocument(); + }); + + it('with duration value', () => { + const testProps = Object.assign( + { + spanBarOptions: { + type: DURATION, + }, + }, + props + ); + render(); + expect(screen.getByText('(9ms)')).toBeInTheDocument(); + }); + + it('with tag value', () => { + const testProps = Object.assign( + { + spanBarOptions: { + type: TAG, + tag: 'tag', + }, + }, + { + ...props, + span: { + process: {}, + tags: [ + { + key: 'tag', + value: 'tag-value', + }, + ], + }, + } + ); + render(); + expect(screen.getByText('(tag-value)')).toBeInTheDocument(); + }); + + it('with process value', () => { + let testProps = Object.assign( + { + spanBarOptions: { + type: TAG, + tag: 'tag', + }, + }, + { + ...props, + span: { + process: { + tags: [ + { + key: 'tag', + value: 'process-value', + }, + ], + }, + tags: [], + }, + } + ); + render(); + expect(screen.getByText('(process-value)')).toBeInTheDocument(); + }); + }); }); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx index 7ace6c3f33d..8d6ee2d9808 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanBarRow.tsx @@ -18,11 +18,12 @@ import * as React from 'react'; import IoAlert from 'react-icons/lib/io/alert'; import IoArrowRightA from 'react-icons/lib/io/arrow-right-a'; -import { GrafanaTheme2 } from '@grafana/data'; +import { GrafanaTheme2, TraceKeyValuePair } from '@grafana/data'; import { stylesFactory, withTheme2 } from '@grafana/ui'; import { autoColor } from '../Theme'; -import { SpanLinkFunc, TNil } from '../types'; +import { DURATION, NONE, TAG } from '../settings/SpanBarSettings'; +import { SpanBarOptions, SpanLinkFunc, TNil } from '../types'; import { SpanLinks } from '../types/links'; import { TraceSpan } from '../types/trace'; @@ -290,6 +291,7 @@ type SpanBarRowProps = { className?: string; theme: GrafanaTheme2; color: string; + spanBarOptions: SpanBarOptions | undefined; columnDivision: number; isChildrenExpanded: boolean; isDetailExpanded: boolean; @@ -352,6 +354,7 @@ export class UnthemedSpanBarRow extends React.PureComponent { const { className, color, + spanBarOptions, columnDivision, isChildrenExpanded, isDetailExpanded, @@ -379,6 +382,7 @@ export class UnthemedSpanBarRow extends React.PureComponent { process: { serviceName }, } = span; const label = formatDuration(duration); + const viewBounds = getViewedBounds(span.startTime, span.startTime + span.duration); const viewStart = viewBounds.start; const viewEnd = viewBounds.end; @@ -473,7 +477,7 @@ export class UnthemedSpanBarRow extends React.PureComponent { )} {rpc ? rpc.operationName : operationName} - | {label} + {this.getSpanBarLabel(span, spanBarOptions, label)} {createSpanLink && (() => { @@ -542,6 +546,35 @@ export class UnthemedSpanBarRow extends React.PureComponent { ); } + + getSpanBarLabel = (span: TraceSpan, spanBarOptions: SpanBarOptions | undefined, duration: string) => { + const type = spanBarOptions?.type ?? ''; + + if (type === NONE) { + return ''; + } else if (type === '' || type === DURATION) { + return `(${duration})`; + } else if (type === TAG) { + const tagKey = spanBarOptions?.tag?.trim() ?? ''; + if (tagKey !== '' && span.tags) { + const tag = span.tags?.find((tag: TraceKeyValuePair) => { + return tag.key === tagKey; + }); + const process = span.process?.tags?.find((process: TraceKeyValuePair) => { + return process.key === tagKey; + }); + + if (tag) { + return `(${tag.value})`; + } + if (process) { + return `(${process.value})`; + } + } + } + + return ''; + }; } export default withTheme2(UnthemedSpanBarRow); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.test.js index 947a805b633..592af162af1 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.test.js +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.test.js @@ -56,7 +56,7 @@ describe('AccordianLogs tests', () => { it('shows the number of log entries', () => { setup(); - expect(screen.getByRole('switch', { name: 'Logs (2)' })).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Events (2)' })).toBeInTheDocument(); }); it('hides log entries when not expanded', () => { diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx index 268f617802b..065fda8cceb 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx @@ -90,7 +90,7 @@ export default function AccordianLogs(props: AccordianLogsProps) { return (
    - {arrow} Logs ({logs.length}) + {arrow} Events ({logs.length}) {isOpen && (
    diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.js b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.js index e13a3550f97..b9423169915 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.js +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.test.js @@ -146,14 +146,14 @@ describe('', () => { }); it('renders the span tags', () => { - const target = ; + const target = ; expect(wrapper.containsMatchingElement(target)).toBe(true); wrapper.find({ data: span.tags }).simulate('toggle'); expect(props.tagsToggle).toHaveBeenLastCalledWith(span.spanID); }); it('renders the process tags', () => { - const target = ; + const target = ; expect(wrapper.containsMatchingElement(target)).toBe(true); wrapper.find({ data: span.process.tags }).simulate('toggle'); expect(props.processToggle).toHaveBeenLastCalledWith(span.spanID); diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx index 8f4b5e5cfae..5bb0ceacf17 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/SpanDetail/index.tsx @@ -213,7 +213,7 @@ export default function SpanDetail(props: SpanDetailProps) {
    tagsToggle(spanID)} @@ -222,7 +222,7 @@ export default function SpanDetail(props: SpanDetailProps) { processToggle(spanID)} diff --git a/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx b/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx index 728af808524..b8ebd36c4d5 100644 --- a/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx +++ b/packages/jaeger-ui-components/src/TraceTimelineViewer/VirtualizedTraceView.tsx @@ -23,7 +23,7 @@ import { stylesFactory, withTheme2, ToolbarButton } from '@grafana/ui'; import { Accessors } from '../ScrollManager'; import { PEER_SERVICE } from '../constants/tag-keys'; -import { SpanLinkFunc, TNil } from '../types'; +import { SpanBarOptions, SpanLinkFunc, TNil } from '../types'; import TTraceTimeline from '../types/TTraceTimeline'; import { TraceLog, TraceSpan, Trace, TraceKeyValuePair, TraceLink, TraceSpanReference } from '../types/trace'; import { getColorByKey } from '../utils/color-generator'; @@ -89,6 +89,7 @@ type TVirtualizedTraceViewOwnProps = { scrollToFirstVisibleSpan: () => void; registerAccessors: (accesors: Accessors) => void; trace: Trace; + spanBarOptions: SpanBarOptions | undefined; linksGetter: (span: TraceSpan, items: TraceKeyValuePair[], itemIndex: number) => TraceLink[]; childrenToggle: (spanID: string) => void; clearShouldScrollToFirstUiFindMatch: () => void; @@ -387,6 +388,7 @@ export class UnthemedVirtualizedTraceView extends React.Component void; traceTimeline: TTraceTimeline; trace: Trace; + spanBarOptions: SpanBarOptions | undefined; updateNextViewRangeTime: (update: ViewRangeTimeUpdate) => void; updateViewRangeTime: TUpdateViewRangeTimeFunction; viewRange: ViewRange; diff --git a/packages/jaeger-ui-components/src/index.ts b/packages/jaeger-ui-components/src/index.ts index f5737eff1f5..bf4885b2b08 100644 --- a/packages/jaeger-ui-components/src/index.ts +++ b/packages/jaeger-ui-components/src/index.ts @@ -1,5 +1,6 @@ export { default as TraceTimelineViewer } from './TraceTimelineViewer'; export { default as TracePageHeader } from './TracePageHeader'; +export { default as SpanBarSettings } from './settings/SpanBarSettings'; export * from './types'; export * from './TraceTimelineViewer/types'; export { default as DetailState } from './TraceTimelineViewer/SpanDetail/DetailState'; diff --git a/packages/jaeger-ui-components/src/settings/SpanBarSettings.tsx b/packages/jaeger-ui-components/src/settings/SpanBarSettings.tsx new file mode 100644 index 00000000000..1de0c0e006d --- /dev/null +++ b/packages/jaeger-ui-components/src/settings/SpanBarSettings.tsx @@ -0,0 +1,90 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { + DataSourceJsonData, + DataSourcePluginOptionsEditorProps, + GrafanaTheme, + toOption, + updateDatasourcePluginJsonDataOption, +} from '@grafana/data'; +import { InlineField, InlineFieldRow, Input, Select, useStyles } 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 {} + +export default function SpanBarSettings({ options, onOptionsChange }: Props) { + const styles = useStyles(getStyles); + const selectOptions = [NONE, DURATION, TAG].map(toOption); + + return ( +
    +

    Span bar label

    + +
    Span bar label lets you add additional info to the span bar row.
    + + + + + updateDatasourcePluginJsonDataOption({ onOptionsChange, options }, 'spanBar', { + ...options.jsonData.spanBar, + tag: v.currentTarget.value, + }) + } + value={options.jsonData.spanBar?.tag || ''} + width={25} + /> + + + )} +
    + ); +} + +const getStyles = (theme: GrafanaTheme) => ({ + infoText: css` + label: infoText; + padding-bottom: ${theme.spacing.md}; + color: ${theme.colors.textSemiWeak}; + `, + + row: css` + label: row; + align-items: baseline; + `, +}); diff --git a/packages/jaeger-ui-components/src/types/index.tsx b/packages/jaeger-ui-components/src/types/index.tsx index 9f1aa8d9115..a491bb9df8b 100644 --- a/packages/jaeger-ui-components/src/types/index.tsx +++ b/packages/jaeger-ui-components/src/types/index.tsx @@ -13,6 +13,7 @@ // limitations under the License. export { TraceSpan, TraceResponse, Trace, TraceProcess, TraceKeyValuePair, TraceLink } from './trace'; -export { SpanLinkFunc, SpanLinkDef } from './links'; +export { SpanBarOptions, SpanBarOptionsData } from '../settings/SpanBarSettings'; export { default as TTraceTimeline } from './TTraceTimeline'; export { default as TNil } from './TNil'; +export { SpanLinkFunc, SpanLinkDef } from './links'; diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile index b9efa36aeea..2b88967dec7 100644 --- a/packaging/docker/Dockerfile +++ b/packaging/docker/Dockerfile @@ -35,13 +35,13 @@ RUN apk add --no-cache openssl --repository=http://dl-cdn.alpinelinux.org/alpine RUN if [ `arch` = "x86_64" ]; then \ apk add --no-cache libaio libnsl && \ ln -s /usr/lib/libnsl.so.2 /usr/lib/libnsl.so.1 && \ - wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.30-r0/glibc-2.30-r0.apk \ - -O /tmp/glibc-2.30-r0.apk && \ - wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.30-r0/glibc-bin-2.30-r0.apk \ - -O /tmp/glibc-bin-2.30-r0.apk && \ - apk add --no-cache --allow-untrusted /tmp/glibc-2.30-r0.apk /tmp/glibc-bin-2.30-r0.apk && \ - rm -f /tmp/glibc-2.30-r0.apk && \ - rm -f /tmp/glibc-bin-2.30-r0.apk && \ + wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.35-r0/glibc-2.35-r0.apk \ + -O /tmp/glibc-2.35-r0.apk && \ + wget https://github.com/sgerrand/alpine-pkg-glibc/releases/download/2.35-r0/glibc-bin-2.35-r0.apk \ + -O /tmp/glibc-bin-2.35-r0.apk && \ + apk add --no-cache --allow-untrusted /tmp/glibc-2.35-r0.apk /tmp/glibc-bin-2.35-r0.apk && \ + rm -f /tmp/glibc-2.35-r0.apk && \ + rm -f /tmp/glibc-bin-2.35-r0.apk && \ rm -f /lib/ld-linux-x86-64.so.2 && \ rm -f /etc/ld.so.cache; \ fi diff --git a/packaging/docker/README.md b/packaging/docker/README.md index ea592592fce..d2b1a889262 100644 --- a/packaging/docker/README.md +++ b/packaging/docker/README.md @@ -16,6 +16,10 @@ Further documentation can be found at http://docs.grafana.org/installation/docke ## Changelog +### v9.0.3 + +- Upgraded glibc version to glibc [2.35](https://sourceware.org/pipermail/libc-alpha/2022-February/136040.html) [#51107](https://github.com/grafana/grafana/pull/51107/files) + ### v8.3.0-beta2 - Our Alpine based images have been upgraded to Alpine [3.14.3](https://alpinelinux.org/posts/Alpine-3.14.3-released.html) [#41922](https://github.com/grafana/grafana/pull/41922) [@hairyhenderson](https://github.com/hairyhenderson) diff --git a/pkg/api/accesscontrol.go b/pkg/api/accesscontrol.go index f834d307068..b750ab71410 100644 --- a/pkg/api/accesscontrol.go +++ b/pkg/api/accesscontrol.go @@ -223,11 +223,12 @@ func (hs *HTTPServer) declareFixedRoles() error { Role: ac.RoleDTO{ Name: "fixed:teams:creator", DisplayName: "Team creator", - Description: "Create teams and read organisation users (required to manage the created teams).", + Description: "Create teams and read organisation users and service accounts (required to manage the created teams).", Group: "Teams", Permissions: []ac.Permission{ {Action: ac.ActionTeamsCreate}, {Action: ac.ActionOrgUsersRead, Scope: ac.ScopeUsersAll}, + {Action: serviceaccounts.ActionRead, Scope: serviceaccounts.ScopeAll}, }, }, Grants: teamCreatorGrants, diff --git a/pkg/api/annotations.go b/pkg/api/annotations.go index 492b87af29b..894f4382ead 100644 --- a/pkg/api/annotations.go +++ b/pkg/api/annotations.go @@ -53,7 +53,7 @@ func (hs *HTTPServer) GetAnnotations(c *models.ReqContext) response.Response { item.DashboardUID = val } else { query := models.GetDashboardQuery{Id: item.DashboardId, OrgId: c.OrgId} - err := hs.dashboardService.GetDashboard(c.Req.Context(), &query) + err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) if err == nil && query.Result != nil { item.DashboardUID = &query.Result.Uid dashboardCache[item.DashboardId] = &query.Result.Uid @@ -82,7 +82,7 @@ func (hs *HTTPServer) PostAnnotation(c *models.ReqContext) response.Response { // overwrite dashboardId when dashboardUID is not empty if cmd.DashboardUID != "" { query := models.GetDashboardQuery{OrgId: c.OrgId, Uid: cmd.DashboardUID} - err := hs.dashboardService.GetDashboard(c.Req.Context(), &query) + err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) if err == nil { cmd.DashboardId = query.Result.Id } @@ -291,7 +291,7 @@ func (hs *HTTPServer) MassDeleteAnnotations(c *models.ReqContext) response.Respo if cmd.DashboardUID != "" { query := models.GetDashboardQuery{OrgId: c.OrgId, Uid: cmd.DashboardUID} - err := hs.dashboardService.GetDashboard(c.Req.Context(), &query) + err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) if err == nil { cmd.DashboardId = query.Result.Id } diff --git a/pkg/api/annotations_test.go b/pkg/api/annotations_test.go index 8b97423c59d..c2bbd3baaeb 100644 --- a/pkg/api/annotations_test.go +++ b/pkg/api/annotations_test.go @@ -343,7 +343,7 @@ func postAnnotationScenario(t *testing.T, desc string, url string, routePattern t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { hs := setupSimpleHTTPServer(nil) hs.SQLStore = store - hs.dashboardService = dashSvc + hs.DashboardService = dashSvc sc := setupScenarioContext(t, url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { @@ -428,7 +428,7 @@ func deleteAnnotationsScenario(t *testing.T, desc string, url string, routePatte t.Run(fmt.Sprintf("%s %s", desc, url), func(t *testing.T) { hs := setupSimpleHTTPServer(nil) hs.SQLStore = store - hs.dashboardService = dashSvc + hs.DashboardService = dashSvc sc := setupScenarioContext(t, url) sc.defaultHandler = routing.Wrap(func(c *models.ReqContext) response.Response { diff --git a/pkg/api/api.go b/pkg/api/api.go index 3d0c0279717..df4aaf68295 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -13,6 +13,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" + publicdashboardsapi "github.com/grafana/grafana/pkg/services/publicdashboards/api" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/web" ) @@ -28,7 +29,7 @@ func (hs *HTTPServer) registerRoutes() { reqGrafanaAdmin := middleware.ReqGrafanaAdmin reqEditorRole := middleware.ReqEditorRole reqOrgAdmin := middleware.ReqOrgAdmin - reqOrgAdminDashOrFolderAdminOrTeamAdmin := middleware.OrgAdminDashOrFolderAdminOrTeamAdmin(hs.SQLStore, hs.dashboardService) + reqOrgAdminDashOrFolderAdminOrTeamAdmin := middleware.OrgAdminDashOrFolderAdminOrTeamAdmin(hs.SQLStore, hs.DashboardService) reqCanAccessTeams := middleware.AdminOrEditorAndFeatureEnabled(hs.Cfg.EditorsCanAdmin) reqSnapshotPublicModeOrSignedIn := middleware.SnapshotPublicModeOrSignedIn(hs.Cfg) redirectFromLegacyPanelEditURL := middleware.RedirectFromLegacyPanelEditURL(hs.Cfg) @@ -75,6 +76,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/admin/orgs", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, orgsAccessEvaluator), hs.Index) r.Get("/admin/orgs/edit/:id", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, orgsAccessEvaluator), hs.Index) r.Get("/admin/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), hs.Index) + r.Get("/admin/storage/*", reqGrafanaAdmin, hs.Index) r.Get("/admin/ldap", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)), hs.Index) r.Get("/styleguide", reqSignedIn, hs.Index) @@ -102,6 +104,10 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/dashboards/*", reqSignedIn, hs.Index) r.Get("/goto/:uid", reqSignedIn, hs.redirectFromShortURL, hs.Index) + if hs.Features.IsEnabled(featuremgmt.FlagPublicDashboards) { + r.Get("/public-dashboards/:accessToken", publicdashboardsapi.SetPublicDashboardFlag(), hs.Index) + } + r.Get("/explore", authorize(func(c *models.ReqContext) { if f, ok := reqSignedIn.(func(c *models.ReqContext)); ok { f(c) @@ -327,10 +333,12 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Any("/plugins/:pluginId/resources/*", hs.CallResource) apiRoute.Get("/plugins/errors", routing.Wrap(hs.GetPluginErrorsList)) - apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) { - pluginRoute.Post("/:pluginId/install", routing.Wrap(hs.InstallPlugin)) - pluginRoute.Post("/:pluginId/uninstall", routing.Wrap(hs.UninstallPlugin)) - }, reqGrafanaAdmin) + if hs.Cfg.PluginAdminEnabled && !hs.Cfg.PluginAdminExternalManageEnabled { + apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) { + pluginRoute.Post("/:pluginId/install", routing.Wrap(hs.InstallPlugin)) + pluginRoute.Post("/:pluginId/uninstall", routing.Wrap(hs.UninstallPlugin)) + }, reqGrafanaAdmin) + } apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) { pluginRoute.Get("/:pluginId/dashboards/", routing.Wrap(hs.GetPluginDashboards)) @@ -388,11 +396,6 @@ func (hs *HTTPServer) registerRoutes() { }) dashboardRoute.Group("/uid/:uid", func(dashUidRoute routing.RouteRegister) { - if hs.Features.IsEnabled(featuremgmt.FlagPublicDashboards) { - dashUidRoute.Get("/public-config", authorize(reqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.GetPublicDashboardConfig)) - dashUidRoute.Post("/public-config", authorize(reqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(hs.SavePublicDashboardConfig)) - } - if hs.ThumbService != nil { dashUidRoute.Get("/img/:kind/:theme", hs.ThumbService.GetImage) if hs.Features.IsEnabled(featuremgmt.FlagDashboardPreviewsAdmin) { @@ -595,7 +598,7 @@ func (hs *HTTPServer) registerRoutes() { // grafana.net proxy r.Any("/api/gnet/*", reqSignedIn, hs.ProxyGnetRequest) - // Gravatar service. + // Gravatar service r.Get("/avatar/:hash", hs.AvatarCacheServer.Handler) // Snapshots @@ -605,13 +608,6 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/api/snapshots-delete/:deleteKey", reqSnapshotPublicModeOrSignedIn, routing.Wrap(hs.DeleteDashboardSnapshotByDeleteKey)) r.Delete("/api/snapshots/:key", reqEditorRole, routing.Wrap(hs.DeleteDashboardSnapshot)) - // Public API - if hs.Features.IsEnabled(featuremgmt.FlagPublicDashboards) { - r.Get("/public-dashboards/:accessToken", middleware.SetPublicDashboardFlag(), hs.Index) - r.Get("/api/public/dashboards/:accessToken", routing.Wrap(hs.GetPublicDashboard)) - r.Post("/api/public/dashboards/:accessToken/panels/:panelId/query", routing.Wrap(hs.QueryPublicDashboard)) - } - // Frontend logs sourceMapStore := frontendlogging.NewSourceMapStore(hs.Cfg, hs.pluginStaticRouteResolver, frontendlogging.ReadSourceMapFromFS) r.Post("/log", middleware.RateLimit(hs.Cfg.Sentry.EndpointRPS, hs.Cfg.Sentry.EndpointBurst, time.Now), diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go index e28a0758726..3111965b170 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -43,7 +43,6 @@ import ( "github.com/grafana/grafana/pkg/services/searchusers" "github.com/grafana/grafana/pkg/services/searchusers/filters" "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/web" "github.com/grafana/grafana/pkg/web/webtest" @@ -341,15 +340,6 @@ func setupHTTPServerWithCfg(t *testing.T, useFakeAccessControl, enableAccessCont return setupHTTPServerWithCfgDb(t, useFakeAccessControl, enableAccessControl, cfg, db, db, featuremgmt.WithFeatures()) } -func setupHTTPServerWithMockDb(t *testing.T, useFakeAccessControl, enableAccessControl bool, features *featuremgmt.FeatureManager) accessControlScenarioContext { - // Use a new conf - cfg := setting.NewCfg() - db := sqlstore.InitTestDB(t) - db.Cfg = setting.NewCfg() - - return setupHTTPServerWithCfgDb(t, useFakeAccessControl, enableAccessControl, cfg, db, mockstore.NewSQLStoreMock(), features) -} - func setupHTTPServerWithCfgDb(t *testing.T, useFakeAccessControl, enableAccessControl bool, cfg *setting.Cfg, db *sqlstore.SQLStore, store sqlstore.Store, features *featuremgmt.FeatureManager) accessControlScenarioContext { t.Helper() @@ -396,7 +386,7 @@ func setupHTTPServerWithCfgDb(t *testing.T, useFakeAccessControl, enableAccessCo AccessControl: ac, teamPermissionsService: teamPermissionService, searchUsersService: searchusers.ProvideUsersService(db, filters.ProvideOSSSearchUserFilter()), - dashboardService: dashboardservice.ProvideDashboardService( + DashboardService: dashboardservice.ProvideDashboardService( cfg, dashboardsStore, nil, features, accesscontrolmock.NewMockedPermissionsService(), accesscontrolmock.NewMockedPermissionsService(), ac, ), diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 8b07440df31..95b88909641 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -144,7 +144,7 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response { // lookup folder title if dash.FolderId > 0 { query := models.GetDashboardQuery{Id: dash.FolderId, OrgId: c.OrgId} - if err := hs.dashboardService.GetDashboard(c.Req.Context(), &query); err != nil { + if err := hs.DashboardService.GetDashboard(c.Req.Context(), &query); err != nil { if errors.Is(err, dashboards.ErrFolderNotFound) { return response.Error(404, "Folder not found", err) } @@ -235,7 +235,7 @@ func (hs *HTTPServer) getDashboardHelper(ctx context.Context, orgID int64, id in query = models.GetDashboardQuery{Id: id, OrgId: orgID} } - if err := hs.dashboardService.GetDashboard(ctx, &query); err != nil { + if err := hs.DashboardService.GetDashboard(ctx, &query); err != nil { return nil, response.Error(404, "Dashboard not found", err) } @@ -262,7 +262,7 @@ func (hs *HTTPServer) deleteDashboard(c *models.ReqContext) response.Response { hs.log.Error("Failed to disconnect library elements", "dashboard", dash.Id, "user", c.SignedInUser.UserId, "error", err) } - err = hs.dashboardService.DeleteDashboard(c.Req.Context(), dash.Id, c.OrgId) + err = hs.DashboardService.DeleteDashboard(c.Req.Context(), dash.Id, c.OrgId) if err != nil { var dashboardErr dashboards.DashboardErr if ok := errors.As(err, &dashboardErr); ok { @@ -387,7 +387,7 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa Overwrite: cmd.Overwrite, } - dashboard, err := hs.dashboardService.SaveDashboard(alerting.WithUAEnabled(ctx, hs.Cfg.UnifiedAlerting.IsEnabled()), dashItem, allowUiUpdate) + dashboard, err := hs.DashboardService.SaveDashboard(alerting.WithUAEnabled(ctx, hs.Cfg.UnifiedAlerting.IsEnabled()), dashItem, allowUiUpdate) if dashboard != nil && hs.entityEventsService != nil { if err := hs.entityEventsService.SaveEvent(ctx, store.SaveEventCmd{ @@ -460,7 +460,7 @@ func (hs *HTTPServer) GetHomeDashboard(c *models.ReqContext) response.Response { if preference.HomeDashboardID != 0 { slugQuery := models.GetDashboardRefByIdQuery{Id: preference.HomeDashboardID} - err := hs.dashboardService.GetDashboardUIDById(c.Req.Context(), &slugQuery) + err := hs.DashboardService.GetDashboardUIDById(c.Req.Context(), &slugQuery) if err == nil { url := models.GetDashboardUrl(slugQuery.Result.Uid, slugQuery.Result.Slug) dashRedirect := dtos.DashboardRedirect{RedirectUri: url} @@ -546,7 +546,7 @@ func (hs *HTTPServer) GetDashboardVersions(c *models.ReqContext) response.Respon OrgId: c.SignedInUser.OrgId, Uid: dashUID, } - if err := hs.dashboardService.GetDashboard(c.Req.Context(), &q); err != nil { + if err := hs.DashboardService.GetDashboard(c.Req.Context(), &q); err != nil { return response.Error(http.StatusBadRequest, "failed to get dashboard by UID", err) } dashID = q.Result.Id @@ -605,7 +605,7 @@ func (hs *HTTPServer) GetDashboardVersion(c *models.ReqContext) response.Respons OrgId: c.SignedInUser.OrgId, Uid: dashUID, } - if err := hs.dashboardService.GetDashboard(c.Req.Context(), &q); err != nil { + if err := hs.DashboardService.GetDashboard(c.Req.Context(), &q); err != nil { return response.Error(http.StatusBadRequest, "failed to get dashboard by UID", err) } dashID = q.Result.Id @@ -782,7 +782,7 @@ func (hs *HTTPServer) RestoreDashboardVersion(c *models.ReqContext) response.Res func (hs *HTTPServer) GetDashboardTags(c *models.ReqContext) { query := models.GetDashboardTagsQuery{OrgId: c.OrgId} - err := hs.dashboardService.GetDashboardTags(c.Req.Context(), &query) + err := hs.DashboardService.GetDashboardTags(c.Req.Context(), &query) if err != nil { c.JsonApiErr(500, "Failed to get tags from database", err) return @@ -803,7 +803,7 @@ func (hs *HTTPServer) GetDashboardUIDs(c *models.ReqContext) { continue } q.Id = id - err = hs.dashboardService.GetDashboardUIDById(c.Req.Context(), q) + err = hs.DashboardService.GetDashboardUIDById(c.Req.Context(), q) if err != nil { continue } diff --git a/pkg/api/dashboard_permission.go b/pkg/api/dashboard_permission.go index 4ae1ce4d36e..6bbec530a18 100644 --- a/pkg/api/dashboard_permission.go +++ b/pkg/api/dashboard_permission.go @@ -143,7 +143,7 @@ func (hs *HTTPServer) UpdateDashboardPermissions(c *models.ReqContext) response. return response.Success("Dashboard permissions updated") } - if err := hs.dashboardService.UpdateDashboardACL(c.Req.Context(), dashID, items); err != nil { + if err := hs.DashboardService.UpdateDashboardACL(c.Req.Context(), dashID, items); err != nil { if errors.Is(err, models.ErrDashboardAclInfoMissing) || errors.Is(err, models.ErrDashboardPermissionDashboardEmpty) { return response.Error(409, err.Error(), err) diff --git a/pkg/api/dashboard_permission_test.go b/pkg/api/dashboard_permission_test.go index 14cc1abc5cf..0b15fee9012 100644 --- a/pkg/api/dashboard_permission_test.go +++ b/pkg/api/dashboard_permission_test.go @@ -39,7 +39,7 @@ func TestDashboardPermissionAPIEndpoint(t *testing.T) { Cfg: settings, SQLStore: mockSQLStore, Features: features, - dashboardService: dashboardservice.ProvideDashboardService( + DashboardService: dashboardservice.ProvideDashboardService( settings, dashboardStore, nil, features, folderPermissions, dashboardPermissions, ac, ), AccessControl: accesscontrolmock.New().WithDisabled(), diff --git a/pkg/api/dashboard_public.go b/pkg/api/dashboard_public.go deleted file mode 100644 index 9850a9cee5d..00000000000 --- a/pkg/api/dashboard_public.go +++ /dev/null @@ -1,141 +0,0 @@ -package api - -import ( - "errors" - "fmt" - "net/http" - "strconv" - "strings" - - "github.com/grafana/grafana/pkg/api/dtos" - "github.com/grafana/grafana/pkg/api/response" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/datasources" - "github.com/grafana/grafana/pkg/web" -) - -// gets public dashboard -func (hs *HTTPServer) GetPublicDashboard(c *models.ReqContext) response.Response { - accessToken := web.Params(c.Req)[":accessToken"] - - dash, err := hs.dashboardService.GetPublicDashboard(c.Req.Context(), accessToken) - if err != nil { - return handleDashboardErr(http.StatusInternalServerError, "Failed to get public dashboard", err) - } - - meta := dtos.DashboardMeta{ - Slug: dash.Slug, - Type: models.DashTypeDB, - CanStar: false, - CanSave: false, - CanEdit: false, - CanAdmin: false, - CanDelete: false, - Created: dash.Created, - Updated: dash.Updated, - Version: dash.Version, - IsFolder: false, - FolderId: dash.FolderId, - PublicDashboardAccessToken: accessToken, - } - - dto := dtos.DashboardFullWithMeta{Meta: meta, Dashboard: dash.Data} - - return response.JSON(http.StatusOK, dto) -} - -// gets public dashboard configuration for dashboard -func (hs *HTTPServer) GetPublicDashboardConfig(c *models.ReqContext) response.Response { - pdc, err := hs.dashboardService.GetPublicDashboardConfig(c.Req.Context(), c.OrgId, web.Params(c.Req)[":uid"]) - if err != nil { - return handleDashboardErr(http.StatusInternalServerError, "Failed to get public dashboard config", err) - } - return response.JSON(http.StatusOK, pdc) -} - -// sets public dashboard configuration for dashboard -func (hs *HTTPServer) SavePublicDashboardConfig(c *models.ReqContext) response.Response { - pubdash := &models.PublicDashboard{} - if err := web.Bind(c.Req, pubdash); err != nil { - return response.Error(http.StatusBadRequest, "bad request data", err) - } - - // Always set the org id to the current auth session orgId - pubdash.OrgId = c.OrgId - - dto := dashboards.SavePublicDashboardConfigDTO{ - OrgId: c.OrgId, - DashboardUid: web.Params(c.Req)[":uid"], - UserId: c.UserId, - PublicDashboard: pubdash, - } - - pubdash, err := hs.dashboardService.SavePublicDashboardConfig(c.Req.Context(), &dto) - if err != nil { - return handleDashboardErr(http.StatusInternalServerError, "Failed to save public dashboard configuration", err) - } - - return response.JSON(http.StatusOK, pubdash) -} - -// QueryPublicDashboard returns all results for a given panel on a public dashboard -// POST /api/public/dashboard/:accessToken/panels/:panelId/query -func (hs *HTTPServer) QueryPublicDashboard(c *models.ReqContext) response.Response { - panelId, err := strconv.ParseInt(web.Params(c.Req)[":panelId"], 10, 64) - if err != nil { - return response.Error(http.StatusBadRequest, "invalid panel ID", err) - } - - dashboard, err := hs.dashboardService.GetPublicDashboard(c.Req.Context(), web.Params(c.Req)[":accessToken"]) - if err != nil { - return response.Error(http.StatusInternalServerError, "could not fetch dashboard", err) - } - - publicDashboard, err := hs.dashboardService.GetPublicDashboardConfig(c.Req.Context(), dashboard.OrgId, dashboard.Uid) - if err != nil { - return response.Error(http.StatusInternalServerError, "could not fetch public dashboard", err) - } - - reqDTO, err := hs.dashboardService.BuildPublicDashboardMetricRequest( - c.Req.Context(), - dashboard, - publicDashboard, - panelId, - ) - if err != nil { - return handleDashboardErr(http.StatusInternalServerError, "Failed to get queries for public dashboard", err) - } - - // Get all needed datasource UIDs from queries - var uids []string - for _, query := range reqDTO.Queries { - uids = append(uids, query.Get("datasource").Get("uid").MustString()) - } - - // Create a temp user with read-only datasource permissions - anonymousUser := &models.SignedInUser{OrgId: dashboard.OrgId, Permissions: make(map[int64]map[string][]string)} - permissions := make(map[string][]string) - datasourceScope := fmt.Sprintf("datasources:uid:%s", strings.Join(uids, ",")) - permissions[datasources.ActionQuery] = []string{datasourceScope} - permissions[datasources.ActionRead] = []string{datasourceScope} - anonymousUser.Permissions[dashboard.OrgId] = permissions - - resp, err := hs.queryDataService.QueryDataMultipleSources(c.Req.Context(), anonymousUser, c.SkipCache, reqDTO, true) - - if err != nil { - return hs.handleQueryMetricsError(err) - } - return hs.toJsonStreamingResponse(resp) -} - -// util to help us unpack a dashboard err or use default http code and message -func handleDashboardErr(defaultCode int, defaultMsg string, err error) response.Response { - var dashboardErr dashboards.DashboardErr - - if ok := errors.As(err, &dashboardErr); ok { - return response.Error(dashboardErr.StatusCode, dashboardErr.Error(), dashboardErr) - } - - return response.Error(defaultCode, defaultMsg, err) -} diff --git a/pkg/api/dashboard_public_test.go b/pkg/api/dashboard_public_test.go deleted file mode 100644 index 3c99b40b74d..00000000000 --- a/pkg/api/dashboard_public_test.go +++ /dev/null @@ -1,630 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io/ioutil" - "net/http" - "strings" - "testing" - - "github.com/google/uuid" - "github.com/grafana/grafana-plugin-sdk-go/backend" - "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/api/dtos" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/infra/localcache" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/datasources" - fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" - "github.com/grafana/grafana/pkg/services/datasources/service" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/query" - "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/web/webtest" -) - -func TestAPIGetPublicDashboard(t *testing.T) { - t.Run("It should 404 if featureflag is not enabled", func(t *testing.T) { - sc := setupHTTPServerWithMockDb(t, false, false, featuremgmt.WithFeatures()) - dashSvc := dashboards.NewFakeDashboardService(t) - dashSvc.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")). - Return(&models.Dashboard{}, nil).Maybe() - sc.hs.dashboardService = dashSvc - - setInitCtxSignedInViewer(sc.initCtx) - response := callAPI( - sc.server, - http.MethodGet, - "/api/public/dashboards", - nil, - t, - ) - assert.Equal(t, http.StatusNotFound, response.Code) - response = callAPI( - sc.server, - http.MethodGet, - "/api/public/dashboards/asdf", - nil, - t, - ) - assert.Equal(t, http.StatusNotFound, response.Code) - }) - - DashboardUid := "dashboard-abcd1234" - token, err := uuid.NewRandom() - require.NoError(t, err) - accessToken := fmt.Sprintf("%x", token) - - testCases := []struct { - Name string - AccessToken string - ExpectedHttpResponse int - publicDashboardResult *models.Dashboard - publicDashboardErr error - }{ - { - Name: "It gets a public dashboard", - AccessToken: accessToken, - ExpectedHttpResponse: http.StatusOK, - publicDashboardResult: &models.Dashboard{ - Data: simplejson.NewFromAny(map[string]interface{}{ - "Uid": DashboardUid, - }), - }, - publicDashboardErr: nil, - }, - { - Name: "It should return 404 if isPublicDashboard is false", - AccessToken: accessToken, - ExpectedHttpResponse: http.StatusNotFound, - publicDashboardResult: nil, - publicDashboardErr: dashboards.ErrPublicDashboardNotFound, - }, - } - - for _, test := range testCases { - t.Run(test.Name, func(t *testing.T) { - sc := setupHTTPServerWithMockDb(t, false, false, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards)) - dashSvc := dashboards.NewFakeDashboardService(t) - dashSvc.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")). - Return(test.publicDashboardResult, test.publicDashboardErr) - sc.hs.dashboardService = dashSvc - - setInitCtxSignedInViewer(sc.initCtx) - response := callAPI( - sc.server, - http.MethodGet, - fmt.Sprintf("/api/public/dashboards/%s", test.AccessToken), - nil, - t, - ) - - assert.Equal(t, test.ExpectedHttpResponse, response.Code) - - if test.publicDashboardErr == nil { - var dashResp dtos.DashboardFullWithMeta - err := json.Unmarshal(response.Body.Bytes(), &dashResp) - require.NoError(t, err) - - assert.Equal(t, DashboardUid, dashResp.Dashboard.Get("Uid").MustString()) - assert.Equal(t, false, dashResp.Meta.CanEdit) - assert.Equal(t, false, dashResp.Meta.CanDelete) - assert.Equal(t, false, dashResp.Meta.CanSave) - } else { - var errResp struct { - Error string `json:"error"` - } - err := json.Unmarshal(response.Body.Bytes(), &errResp) - require.NoError(t, err) - assert.Equal(t, test.publicDashboardErr.Error(), errResp.Error) - } - }) - } -} - -func TestAPIGetPublicDashboardConfig(t *testing.T) { - pubdash := &models.PublicDashboard{IsEnabled: true} - - testCases := []struct { - Name string - DashboardUid string - ExpectedHttpResponse int - PublicDashboardResult *models.PublicDashboard - PublicDashboardError error - }{ - { - Name: "retrieves public dashboard config when dashboard is found", - DashboardUid: "1", - ExpectedHttpResponse: http.StatusOK, - PublicDashboardResult: pubdash, - PublicDashboardError: nil, - }, - { - Name: "returns 404 when dashboard not found", - DashboardUid: "77777", - ExpectedHttpResponse: http.StatusNotFound, - PublicDashboardResult: nil, - PublicDashboardError: dashboards.ErrDashboardNotFound, - }, - { - Name: "returns 500 when internal server error", - DashboardUid: "1", - ExpectedHttpResponse: http.StatusInternalServerError, - PublicDashboardResult: nil, - PublicDashboardError: errors.New("database broken"), - }, - } - - for _, test := range testCases { - t.Run(test.Name, func(t *testing.T) { - sc := setupHTTPServerWithMockDb(t, false, false, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards)) - dashSvc := dashboards.NewFakeDashboardService(t) - dashSvc.On("GetPublicDashboardConfig", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")). - Return(test.PublicDashboardResult, test.PublicDashboardError) - sc.hs.dashboardService = dashSvc - - setInitCtxSignedInViewer(sc.initCtx) - response := callAPI( - sc.server, - http.MethodGet, - "/api/dashboards/uid/1/public-config", - nil, - t, - ) - - assert.Equal(t, test.ExpectedHttpResponse, response.Code) - - if response.Code == http.StatusOK { - var pdcResp models.PublicDashboard - err := json.Unmarshal(response.Body.Bytes(), &pdcResp) - require.NoError(t, err) - assert.Equal(t, test.PublicDashboardResult, &pdcResp) - } - }) - } -} - -func TestApiSavePublicDashboardConfig(t *testing.T) { - testCases := []struct { - Name string - DashboardUid string - publicDashboardConfig *models.PublicDashboard - ExpectedHttpResponse int - saveDashboardError error - }{ - { - Name: "returns 200 when update persists", - DashboardUid: "1", - publicDashboardConfig: &models.PublicDashboard{IsEnabled: true}, - ExpectedHttpResponse: http.StatusOK, - saveDashboardError: nil, - }, - { - Name: "returns 500 when not persisted", - ExpectedHttpResponse: http.StatusInternalServerError, - publicDashboardConfig: &models.PublicDashboard{}, - saveDashboardError: errors.New("backend failed to save"), - }, - { - Name: "returns 404 when dashboard not found", - ExpectedHttpResponse: http.StatusNotFound, - publicDashboardConfig: &models.PublicDashboard{}, - saveDashboardError: dashboards.ErrDashboardNotFound, - }, - } - - for _, test := range testCases { - t.Run(test.Name, func(t *testing.T) { - sc := setupHTTPServerWithMockDb(t, false, false, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards)) - - dashSvc := dashboards.NewFakeDashboardService(t) - dashSvc.On("SavePublicDashboardConfig", mock.Anything, mock.AnythingOfType("*dashboards.SavePublicDashboardConfigDTO")). - Return(&models.PublicDashboard{IsEnabled: true}, test.saveDashboardError) - sc.hs.dashboardService = dashSvc - - setInitCtxSignedInViewer(sc.initCtx) - response := callAPI( - sc.server, - http.MethodPost, - "/api/dashboards/uid/1/public-config", - strings.NewReader(`{ "isPublic": true }`), - t, - ) - - assert.Equal(t, test.ExpectedHttpResponse, response.Code) - - // check the result if it's a 200 - if response.Code == http.StatusOK { - val, err := json.Marshal(test.publicDashboardConfig) - require.NoError(t, err) - assert.Equal(t, string(val), response.Body.String()) - } - }) - } -} - -// `/public/dashboards/:uid/query`` endpoint test -func TestAPIQueryPublicDashboard(t *testing.T) { - queryReturnsError := false - - qds := query.ProvideService( - nil, - &fakeDatasources.FakeCacheService{ - DataSources: []*datasources.DataSource{ - {Uid: "mysqlds"}, - {Uid: "promds"}, - {Uid: "promds2"}, - }, - }, - nil, - &fakePluginRequestValidator{}, - &fakeDatasources.FakeDataSourceService{}, - &fakePluginClient{ - QueryDataHandlerFunc: func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - if queryReturnsError { - return nil, errors.New("error") - } - - resp := backend.Responses{} - - for _, query := range req.Queries { - resp[query.RefID] = backend.DataResponse{ - Frames: []*data.Frame{ - { - RefID: query.RefID, - Name: "query-" + query.RefID, - }, - }, - } - } - return &backend.QueryDataResponse{Responses: resp}, nil - }, - }, - &fakeOAuthTokenService{}, - ) - - setup := func(enabled bool) (*webtest.Server, *dashboards.FakeDashboardService) { - fakeDashboardService := &dashboards.FakeDashboardService{} - - return SetupAPITestServer(t, func(hs *HTTPServer) { - hs.queryDataService = qds - hs.Features = featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards, enabled) - hs.dashboardService = fakeDashboardService - }), fakeDashboardService - } - - t.Run("Status code is 404 when feature toggle is disabled", func(t *testing.T) { - server, _ := setup(false) - - req := server.NewPostRequest( - "/api/public/dashboards/abc123/panels/2/query", - strings.NewReader("{}"), - ) - resp, err := server.SendJSON(req) - require.NoError(t, err) - require.NoError(t, resp.Body.Close()) - require.Equal(t, http.StatusNotFound, resp.StatusCode) - }) - - t.Run("Status code is 400 when the panel ID is invalid", func(t *testing.T) { - server, _ := setup(true) - - req := server.NewPostRequest( - "/api/public/dashboards/abc123/panels/notanumber/query", - strings.NewReader("{}"), - ) - resp, err := server.SendJSON(req) - require.NoError(t, err) - require.NoError(t, resp.Body.Close()) - require.Equal(t, http.StatusBadRequest, resp.StatusCode) - }) - - t.Run("Returns query data when feature toggle is enabled", func(t *testing.T) { - server, fakeDashboardService := setup(true) - - fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil) - fakeDashboardService.On("GetPublicDashboardConfig", mock.Anything, mock.Anything, mock.Anything).Return(&models.PublicDashboard{}, nil) - - fakeDashboardService.On( - "BuildPublicDashboardMetricRequest", - mock.Anything, - mock.Anything, - mock.Anything, - int64(2), - ).Return(dtos.MetricRequest{ - Queries: []*simplejson.Json{ - simplejson.MustJson([]byte(` - { - "datasource": { - "type": "prometheus", - "uid": "promds" - }, - "exemplar": true, - "expr": "query_2_A", - "interval": "", - "legendFormat": "", - "refId": "A" - } - `)), - }, - }, nil) - req := server.NewPostRequest( - "/api/public/dashboards/abc123/panels/2/query", - strings.NewReader("{}"), - ) - resp, err := server.SendJSON(req) - require.NoError(t, err) - bodyBytes, err := ioutil.ReadAll(resp.Body) - require.NoError(t, err) - require.JSONEq( - t, - `{ - "results": { - "A": { - "frames": [ - { - "data": { - "values": [] - }, - "schema": { - "fields": [], - "refId": "A", - "name": "query-A" - } - } - ] - } - } - }`, - string(bodyBytes), - ) - require.NoError(t, resp.Body.Close()) - require.Equal(t, http.StatusOK, resp.StatusCode) - }) - - t.Run("Status code is 500 when the query fails", func(t *testing.T) { - server, fakeDashboardService := setup(true) - - fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil) - fakeDashboardService.On("GetPublicDashboardConfig", mock.Anything, mock.Anything, mock.Anything).Return(&models.PublicDashboard{}, nil) - fakeDashboardService.On( - "BuildPublicDashboardMetricRequest", - mock.Anything, - mock.Anything, - mock.Anything, - int64(2), - ).Return(dtos.MetricRequest{ - Queries: []*simplejson.Json{ - simplejson.MustJson([]byte(` - { - "datasource": { - "type": "prometheus", - "uid": "promds" - }, - "exemplar": true, - "expr": "query_2_A", - "interval": "", - "legendFormat": "", - "refId": "A" - } - `)), - }, - }, nil) - req := server.NewPostRequest( - "/api/public/dashboards/abc123/panels/2/query", - strings.NewReader("{}"), - ) - queryReturnsError = true - resp, err := server.SendJSON(req) - require.NoError(t, err) - require.NoError(t, resp.Body.Close()) - require.Equal(t, http.StatusInternalServerError, resp.StatusCode) - queryReturnsError = false - }) - - t.Run("Status code is 200 when a panel has queries from multiple datasources", func(t *testing.T) { - server, fakeDashboardService := setup(true) - - fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil) - fakeDashboardService.On("GetPublicDashboardConfig", mock.Anything, mock.Anything, mock.Anything).Return(&models.PublicDashboard{}, nil) - fakeDashboardService.On( - "BuildPublicDashboardMetricRequest", - mock.Anything, - mock.Anything, - mock.Anything, - int64(2), - ).Return(dtos.MetricRequest{ - Queries: []*simplejson.Json{ - simplejson.MustJson([]byte(` - { - "datasource": { - "type": "prometheus", - "uid": "promds" - }, - "exemplar": true, - "expr": "query_2_A", - "interval": "", - "legendFormat": "", - "refId": "A" - } - `)), - simplejson.MustJson([]byte(` - { - "datasource": { - "type": "prometheus", - "uid": "promds2" - }, - "exemplar": true, - "expr": "query_2_B", - "interval": "", - "legendFormat": "", - "refId": "B" - } - `)), - }, - }, nil) - req := server.NewPostRequest( - "/api/public/dashboards/abc123/panels/2/query", - strings.NewReader("{}"), - ) - resp, err := server.SendJSON(req) - require.NoError(t, err) - bodyBytes, err := ioutil.ReadAll(resp.Body) - require.NoError(t, err) - require.JSONEq( - t, - `{ - "results": { - "A": { - "frames": [ - { - "data": { - "values": [] - }, - "schema": { - "fields": [], - "refId": "A", - "name": "query-A" - } - } - ] - }, - "B": { - "frames": [ - { - "data": { - "values": [] - }, - "schema": { - "fields": [], - "refId": "B", - "name": "query-B" - } - } - ] - } - } - }`, - string(bodyBytes), - ) - require.NoError(t, resp.Body.Close()) - require.Equal(t, http.StatusOK, resp.StatusCode) - }) -} - -func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T) { - config := setting.NewCfg() - db := sqlstore.InitTestDB(t) - scenario := setupHTTPServerWithCfgDb(t, false, false, config, db, db, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards)) - scenario.initCtx.SkipCache = true - cacheService := service.ProvideCacheService(localcache.ProvideService(), db) - qds := query.ProvideService( - nil, - cacheService, - nil, - &fakePluginRequestValidator{}, - &fakeDatasources.FakeDataSourceService{}, - &fakePluginClient{ - QueryDataHandlerFunc: func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { - resp := backend.Responses{ - "A": backend.DataResponse{ - Frames: []*data.Frame{{}}, - }, - } - return &backend.QueryDataResponse{Responses: resp}, nil - }, - }, - &fakeOAuthTokenService{}, - ) - scenario.hs.queryDataService = qds - - _ = db.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{ - Uid: "ds1", - OrgId: 1, - Name: "laban", - Type: datasources.DS_MYSQL, - Access: datasources.DS_ACCESS_DIRECT, - Url: "http://test", - Database: "site", - ReadOnly: true, - }) - - // Create Dashboard - saveDashboardCmd := models.SaveDashboardCommand{ - OrgId: 1, - FolderId: 1, - IsFolder: false, - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "id": nil, - "title": "test", - "panels": []map[string]interface{}{ - { - "id": 1, - "targets": []map[string]interface{}{ - { - "datasource": map[string]string{ - "type": "mysql", - "uid": "ds1", - }, - "refId": "A", - }, - }, - }, - }, - }), - } - dashboard, _ := scenario.dashboardsStore.SaveDashboard(saveDashboardCmd) - - // Create public dashboard - savePubDashboardCmd := &dashboards.SavePublicDashboardConfigDTO{ - DashboardUid: dashboard.Uid, - OrgId: dashboard.OrgId, - PublicDashboard: &models.PublicDashboard{ - IsEnabled: true, - }, - } - - pubdash, err := scenario.hs.dashboardService.SavePublicDashboardConfig(context.Background(), savePubDashboardCmd) - require.NoError(t, err) - - response := callAPI( - scenario.server, - http.MethodPost, - fmt.Sprintf("/api/public/dashboards/%s/panels/1/query", pubdash.AccessToken), - strings.NewReader(`{}`), - t, - ) - - require.Equal(t, http.StatusOK, response.Code) - bodyBytes, err := ioutil.ReadAll(response.Body) - require.NoError(t, err) - require.JSONEq( - t, - `{ - "results": { - "A": { - "frames": [ - { - "data": { - "values": [] - }, - "schema": { - "fields": [] - } - } - ] - } - } - }`, - string(bodyBytes), - ) -} diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 7ababd06b01..d43a7383faf 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -139,7 +139,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { SQLStore: mockSQLStore, AccessControl: accesscontrolmock.New(), Features: featuremgmt.WithFeatures(), - dashboardService: dashboardService, + DashboardService: dashboardService, dashboardVersionService: fakeDashboardVersionService, } hs.CoremodelStaticRegistry, hs.CoremodelRegistry = setupDashboardCoremodel(t) @@ -259,7 +259,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { LibraryElementService: &mockLibraryElementService{}, SQLStore: mockSQLStore, AccessControl: accesscontrolmock.New(), - dashboardService: dashboardService, + DashboardService: dashboardService, dashboardVersionService: fakeDashboardVersionService, } hs.CoremodelStaticRegistry, hs.CoremodelRegistry = setupDashboardCoremodel(t) @@ -900,7 +900,7 @@ func TestDashboardAPIEndpoint(t *testing.T) { dashboardProvisioningService: mockDashboardProvisioningService{}, SQLStore: mockSQLStore, AccessControl: accesscontrolmock.New(), - dashboardService: dashboardService, + DashboardService: dashboardService, } hs.CoremodelStaticRegistry, hs.CoremodelRegistry = setupDashboardCoremodel(t) hs.callGetDashboard(sc) @@ -954,7 +954,7 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr cfg, dashboardStore, nil, features, folderPermissions, dashboardPermissions, ac, ), - dashboardService: dashboardService, + DashboardService: dashboardService, } hs.CoremodelStaticRegistry, hs.CoremodelRegistry = setupDashboardCoremodel(t) @@ -986,7 +986,7 @@ func (hs *HTTPServer) callGetDashboardVersions(sc *scenarioContext) { func (hs *HTTPServer) callDeleteDashboardByUID(t *testing.T, sc *scenarioContext, mockDashboard *dashboards.FakeDashboardService) { - hs.dashboardService = mockDashboard + hs.DashboardService = mockDashboard sc.handlerFunc = hs.DeleteDashboardByUID sc.fakeReqWithParams("DELETE", sc.url, map[string]string{}).exec() } @@ -1018,7 +1018,7 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s pluginStore: &fakePluginStore{}, LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, - dashboardService: dashboardService, + DashboardService: dashboardService, folderService: folderService, Features: featuremgmt.WithFeatures(), } @@ -1088,7 +1088,7 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout QuotaService: "a.QuotaService{Cfg: cfg}, LibraryPanelService: &mockLibraryPanelService{}, LibraryElementService: &mockLibraryElementService{}, - dashboardService: mock, + DashboardService: mock, SQLStore: sqlStore, Features: featuremgmt.WithFeatures(), dashboardVersionService: fakeDashboardVersionService, diff --git a/pkg/api/dtos/models.go b/pkg/api/dtos/models.go index c0fe84cb2dc..343b5f6c1b2 100644 --- a/pkg/api/dtos/models.go +++ b/pkg/api/dtos/models.go @@ -70,6 +70,8 @@ type MetricRequest struct { // required: false Debug bool `json:"debug"` + PublicDashboardAccessToken string `json:"publicDashboardAccessToken"` + HTTPRequest *http.Request `json:"-"` } diff --git a/pkg/api/fakes.go b/pkg/api/fakes.go index 23b5a89c52d..6b1eda6f2ff 100644 --- a/pkg/api/fakes.go +++ b/pkg/api/fakes.go @@ -6,6 +6,28 @@ import ( "github.com/grafana/grafana/pkg/plugins" ) +type fakePluginManager struct { + plugins map[string]fakePlugin +} + +type fakePlugin struct { + pluginID string + version string +} + +func (pm *fakePluginManager) Add(_ context.Context, pluginID, version string) error { + pm.plugins[pluginID] = fakePlugin{ + pluginID: pluginID, + version: version, + } + return nil +} + +func (pm *fakePluginManager) Remove(_ context.Context, pluginID string) error { + delete(pm.plugins, pluginID) + return nil +} + type fakePluginStore struct { plugins.Store diff --git a/pkg/api/folder_permission.go b/pkg/api/folder_permission.go index e622c21ab05..892e9e043aa 100644 --- a/pkg/api/folder_permission.go +++ b/pkg/api/folder_permission.go @@ -126,7 +126,7 @@ func (hs *HTTPServer) UpdateFolderPermissions(c *models.ReqContext) response.Res return response.Success("Dashboard permissions updated") } - if err := hs.dashboardService.UpdateDashboardACL(c.Req.Context(), folder.Id, items); err != nil { + if err := hs.DashboardService.UpdateDashboardACL(c.Req.Context(), folder.Id, items); err != nil { if errors.Is(err, models.ErrDashboardAclInfoMissing) { err = models.ErrFolderAclInfoMissing } diff --git a/pkg/api/folder_permission_test.go b/pkg/api/folder_permission_test.go index 48e88febbed..093522d2d9e 100644 --- a/pkg/api/folder_permission_test.go +++ b/pkg/api/folder_permission_test.go @@ -42,7 +42,7 @@ func TestFolderPermissionAPIEndpoint(t *testing.T) { folderService: folderService, folderPermissionsService: folderPermissions, dashboardPermissionsService: dashboardPermissions, - dashboardService: service.ProvideDashboardService( + DashboardService: service.ProvideDashboardService( settings, dashboardStore, nil, features, folderPermissions, dashboardPermissions, ac, ), AccessControl: accesscontrolmock.New().WithDisabled(), diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index e633beffd1f..ac80801de41 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -62,6 +62,8 @@ import ( pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings/service" pref "github.com/grafana/grafana/pkg/services/preference" "github.com/grafana/grafana/pkg/services/provisioning" + + publicdashboardsApi "github.com/grafana/grafana/pkg/services/publicdashboards/api" "github.com/grafana/grafana/pkg/services/query" "github.com/grafana/grafana/pkg/services/queryhistory" "github.com/grafana/grafana/pkg/services/quota" @@ -148,7 +150,7 @@ type HTTPServer struct { authenticator loginpkg.Authenticator teamPermissionsService accesscontrol.TeamPermissionsService NotificationService *notifications.NotificationService - dashboardService dashboards.DashboardService + DashboardService dashboards.DashboardService dashboardProvisioningService dashboards.DashboardProvisioningService folderService dashboards.FolderService DatasourcePermissionsService permissions.DatasourcePermissionsService @@ -163,6 +165,7 @@ type HTTPServer struct { folderPermissionsService accesscontrol.FolderPermissionsService dashboardPermissionsService accesscontrol.DashboardPermissionsService dashboardVersionService dashver.Service + PublicDashboardsApi *publicdashboardsApi.Api starService star.Service CoremodelRegistry *registry.Generic CoremodelStaticRegistry *registry.Static @@ -202,7 +205,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi teamsPermissionsService accesscontrol.TeamPermissionsService, folderPermissionsService accesscontrol.FolderPermissionsService, dashboardPermissionsService accesscontrol.DashboardPermissionsService, dashboardVersionService dashver.Service, starService star.Service, csrfService csrf.Service, coremodelRegistry *registry.Generic, coremodelStaticRegistry *registry.Static, - kvStore kvstore.KVStore, secretsMigrator secrets.Migrator, remoteSecretsCheck secretsKV.UseRemoteSecretsPluginCheck, + kvStore kvstore.KVStore, secretsMigrator secrets.Migrator, remoteSecretsCheck secretsKV.UseRemoteSecretsPluginCheck, publicDashboardsApi *publicdashboardsApi.Api, ) (*HTTPServer, error) { web.Env = cfg.Env m := web.New() @@ -267,7 +270,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi authInfoService: authInfoService, authenticator: authenticator, NotificationService: notificationService, - dashboardService: dashboardService, + DashboardService: dashboardService, dashboardProvisioningService: dashboardProvisioningService, folderService: folderService, DatasourcePermissionsService: datasourcePermissionsService, @@ -287,6 +290,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi CoremodelRegistry: coremodelRegistry, CoremodelStaticRegistry: coremodelStaticRegistry, kvStore: kvStore, + PublicDashboardsApi: publicDashboardsApi, secretsMigrator: secretsMigrator, } if hs.Listener != nil { diff --git a/pkg/api/index.go b/pkg/api/index.go index aaca41afe05..00c375931c6 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -294,6 +294,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs * Url: hs.Cfg.AppSubURL + "/org/apikeys", }) } + if enableServiceAccount(hs, c) { configNodes = append(configNodes, &dtos.NavLink{ Text: "Service accounts", @@ -406,7 +407,7 @@ func (hs *HTTPServer) buildStarredItemsNavLinks(c *models.ReqContext, prefs *pre Id: dashboardId, OrgId: c.OrgId, } - err := hs.dashboardService.GetDashboard(c.Req.Context(), query) + err := hs.DashboardService.GetDashboard(c.Req.Context(), query) if err == nil { starredDashboards = append(starredDashboards, query.Result) } @@ -458,6 +459,15 @@ func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b }) } + if hs.Features.IsEnabled(featuremgmt.FlagScenes) { + dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ + Text: "Scenes", + Id: "scenes", + Url: hs.Cfg.AppSubURL + "/scenes", + Icon: "apps", + }) + } + if hasEditPerm { dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, @@ -646,6 +656,16 @@ func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink { }) } + if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)) && hs.Features.IsEnabled(featuremgmt.FlagStorage) { + adminNavLinks = append(adminNavLinks, &dtos.NavLink{ + Text: "Storage", + Id: "storage", + Description: "Manage file storage", + Icon: "cube", + Url: hs.Cfg.AppSubURL + "/admin/storage", + }) + } + if hs.Cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)) { adminNavLinks = append(adminNavLinks, &dtos.NavLink{ Text: "LDAP", Id: "ldap", Url: hs.Cfg.AppSubURL + "/admin/ldap", Icon: "book", @@ -663,7 +683,7 @@ func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink { func (hs *HTTPServer) editorInAnyFolder(c *models.ReqContext) bool { hasEditPermissionInFoldersQuery := models.HasEditPermissionInFoldersQuery{SignedInUser: c.SignedInUser} - if err := hs.dashboardService.HasEditPermissionInFolders(c.Req.Context(), &hasEditPermissionInFoldersQuery); err != nil { + if err := hs.DashboardService.HasEditPermissionInFolders(c.Req.Context(), &hasEditPermissionInFoldersQuery); err != nil { return false } return hasEditPermissionInFoldersQuery.Result diff --git a/pkg/api/playlist_play.go b/pkg/api/playlist_play.go index c75a550d579..035c4d402f4 100644 --- a/pkg/api/playlist_play.go +++ b/pkg/api/playlist_play.go @@ -16,7 +16,7 @@ func (hs *HTTPServer) populateDashboardsByID(ctx context.Context, dashboardByIDs if len(dashboardByIDs) > 0 { dashboardQuery := models.GetDashboardsQuery{DashboardIds: dashboardByIDs} - if err := hs.dashboardService.GetDashboards(ctx, &dashboardQuery); err != nil { + if err := hs.DashboardService.GetDashboards(ctx, &dashboardQuery); err != nil { return result, err } diff --git a/pkg/api/plugins_test.go b/pkg/api/plugins_test.go index 9e869285efd..8aefeac8e62 100644 --- a/pkg/api/plugins_test.go +++ b/pkg/api/plugins_test.go @@ -4,25 +4,96 @@ import ( "context" "encoding/json" "fmt" + "io" "io/ioutil" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" - "github.com/grafana/grafana/pkg/infra/log/logtest" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/log/logtest" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web/webtest" ) +func Test_PluginsInstallAndUninstall(t *testing.T) { + type tc struct { + pluginAdminEnabled bool + pluginAdminExternalManageEnabled bool + expectedHTTPStatus int + expectedHTTPBody string + } + tcs := []tc{ + {pluginAdminEnabled: true, pluginAdminExternalManageEnabled: true, expectedHTTPStatus: 404, expectedHTTPBody: "404 page not found\n"}, + {pluginAdminEnabled: true, pluginAdminExternalManageEnabled: false, expectedHTTPStatus: 200, expectedHTTPBody: ""}, + {pluginAdminEnabled: false, pluginAdminExternalManageEnabled: true, expectedHTTPStatus: 404, expectedHTTPBody: "404 page not found\n"}, + {pluginAdminEnabled: false, pluginAdminExternalManageEnabled: false, expectedHTTPStatus: 404, expectedHTTPBody: "404 page not found\n"}, + } + + testName := func(action string, testCase tc) string { + return fmt.Sprintf("%s request returns %d when adminEnabled: %t and externalEnabled: %t", + action, testCase.expectedHTTPStatus, testCase.pluginAdminEnabled, testCase.pluginAdminExternalManageEnabled) + } + + pm := &fakePluginManager{ + plugins: make(map[string]fakePlugin), + } + for _, tc := range tcs { + srv := SetupAPITestServer(t, func(hs *HTTPServer) { + hs.Cfg = &setting.Cfg{ + PluginAdminEnabled: tc.pluginAdminEnabled, + PluginAdminExternalManageEnabled: tc.pluginAdminExternalManageEnabled, + } + hs.pluginManager = pm + }) + + t.Run(testName("Install", tc), func(t *testing.T) { + req := srv.NewPostRequest("/api/plugins/test/install", strings.NewReader("{ \"version\": \"1.0.2\" }")) + webtest.RequestWithSignedInUser(req, &models.SignedInUser{UserId: 1, OrgId: 1, OrgRole: models.ROLE_EDITOR, IsGrafanaAdmin: true}) + resp, err := srv.SendJSON(req) + require.NoError(t, err) + + body := new(strings.Builder) + _, err = io.Copy(body, resp.Body) + require.NoError(t, err) + require.Equal(t, tc.expectedHTTPBody, body.String()) + require.NoError(t, resp.Body.Close()) + require.Equal(t, tc.expectedHTTPStatus, resp.StatusCode) + + if tc.expectedHTTPStatus == 200 { + require.Equal(t, fakePlugin{pluginID: "test", version: "1.0.2"}, pm.plugins["test"]) + } + }) + + t.Run(testName("Uninstall", tc), func(t *testing.T) { + req := srv.NewPostRequest("/api/plugins/test/uninstall", strings.NewReader("{}")) + webtest.RequestWithSignedInUser(req, &models.SignedInUser{UserId: 1, OrgId: 1, OrgRole: models.ROLE_VIEWER, IsGrafanaAdmin: true}) + resp, err := srv.SendJSON(req) + require.NoError(t, err) + + body := new(strings.Builder) + _, err = io.Copy(body, resp.Body) + require.NoError(t, err) + require.Equal(t, tc.expectedHTTPBody, body.String()) + require.NoError(t, resp.Body.Close()) + require.Equal(t, tc.expectedHTTPStatus, resp.StatusCode) + + if tc.expectedHTTPStatus == 200 { + require.Empty(t, pm.plugins) + } + }) + } +} + func Test_GetPluginAssets(t *testing.T) { pluginID := "test-plugin" pluginDir := "." diff --git a/pkg/api/preferences.go b/pkg/api/preferences.go index e1acad29947..e13f6b07837 100644 --- a/pkg/api/preferences.go +++ b/pkg/api/preferences.go @@ -31,7 +31,7 @@ func (hs *HTTPServer) SetHomeDashboard(c *models.ReqContext) response.Response { dashboardID := cmd.HomeDashboardID if cmd.HomeDashboardUID != nil { query := models.GetDashboardQuery{Uid: *cmd.HomeDashboardUID} - err := hs.dashboardService.GetDashboard(c.Req.Context(), &query) + err := hs.DashboardService.GetDashboard(c.Req.Context(), &query) if err != nil { return response.Error(404, "Dashboard not found", err) } @@ -65,7 +65,7 @@ func (hs *HTTPServer) getPreferencesFor(ctx context.Context, orgID, userID, team // when homedashboardID is 0, that means it is the default home dashboard, no UID would be returned in the response if preference.HomeDashboardID != 0 { query := models.GetDashboardQuery{Id: preference.HomeDashboardID, OrgId: orgID} - err = hs.dashboardService.GetDashboard(ctx, &query) + err = hs.DashboardService.GetDashboard(ctx, &query) if err == nil { dashboardUID = query.Result.Uid } @@ -105,7 +105,7 @@ func (hs *HTTPServer) updatePreferencesFor(ctx context.Context, orgID, userID, t dashboardID := dtoCmd.HomeDashboardID if dtoCmd.HomeDashboardUID != nil { query := models.GetDashboardQuery{Uid: *dtoCmd.HomeDashboardUID, OrgId: orgID} - err := hs.dashboardService.GetDashboard(ctx, &query) + err := hs.DashboardService.GetDashboard(ctx, &query) if err != nil { return response.Error(404, "Dashboard not found", err) } @@ -151,7 +151,7 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te dashboardID := dtoCmd.HomeDashboardID if dtoCmd.HomeDashboardUID != nil { query := models.GetDashboardQuery{Uid: *dtoCmd.HomeDashboardUID, OrgId: orgID} - err := hs.dashboardService.GetDashboard(ctx, &query) + err := hs.DashboardService.GetDashboard(ctx, &query) if err != nil { return response.Error(404, "Dashboard not found", err) } diff --git a/pkg/api/preferences_test.go b/pkg/api/preferences_test.go index e62af23b504..5796ad1e867 100644 --- a/pkg/api/preferences_test.go +++ b/pkg/api/preferences_test.go @@ -40,7 +40,7 @@ func TestAPIEndpoint_GetCurrentOrgPreferences_LegacyAccessControl(t *testing.T) q.Result = &models.Dashboard{Uid: "home", Id: 1} }).Return(nil) - sc.hs.dashboardService = dashSvc + sc.hs.DashboardService = dashSvc prefService := preftest.NewPreferenceServiceFake() prefService.ExpectedPreference = &pref.Preference{HomeDashboardID: 1, Theme: "dark"} @@ -169,7 +169,7 @@ func TestAPIEndpoint_PatchUserPreferences(t *testing.T) { q := args.Get(1).(*models.GetDashboardQuery) q.Result = &models.Dashboard{Uid: "home", Id: 1} }).Return(nil) - sc.hs.dashboardService = dashSvc + sc.hs.DashboardService = dashSvc t.Run("Returns 200 on success", func(t *testing.T) { response := callAPI(sc.server, http.MethodPatch, patchUserPreferencesUrl, input, t) assert.Equal(t, http.StatusOK, response.Code) diff --git a/pkg/api/stars.go b/pkg/api/stars.go index 73eeae19ab1..fb8e76114ac 100644 --- a/pkg/api/stars.go +++ b/pkg/api/stars.go @@ -26,7 +26,7 @@ func (hs *HTTPServer) GetStars(c *models.ReqContext) response.Response { Id: dashboardId, OrgId: c.OrgId, } - err := hs.dashboardService.GetDashboard(c.Req.Context(), query) + err := hs.DashboardService.GetDashboard(c.Req.Context(), query) // Grafana admin users may have starred dashboards in multiple orgs. This will avoid returning errors when the dashboard is in another org if err == nil { diff --git a/pkg/models/dashboard_queries.go b/pkg/models/dashboard_queries.go index 0490071b116..7f27011e73c 100644 --- a/pkg/models/dashboard_queries.go +++ b/pkg/models/dashboard_queries.go @@ -4,7 +4,23 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" ) -func GetQueriesFromDashboard(dashboard *simplejson.Json) map[int64][]*simplejson.Json { +func GetUniqueDashboardDatasourceUids(dashboard *simplejson.Json) []string { + var datasourceUids []string + exists := map[string]bool{} + + for _, panelObj := range dashboard.Get("panels").MustArray() { + panel := simplejson.NewFromAny(panelObj) + uid := panel.Get("datasource").Get("uid").MustString() + if _, ok := exists[uid]; !ok { + datasourceUids = append(datasourceUids, uid) + exists[uid] = true + } + } + + return datasourceUids +} + +func GroupQueriesByPanelId(dashboard *simplejson.Json) map[int64][]*simplejson.Json { result := make(map[int64][]*simplejson.Json) for _, panelObj := range dashboard.Get("panels").MustArray() { diff --git a/pkg/models/dashboard_queries_test.go b/pkg/models/dashboard_queries_test.go index 95daa3bae22..ed02d11c88b 100644 --- a/pkg/models/dashboard_queries_test.go +++ b/pkg/models/dashboard_queries_test.go @@ -56,6 +56,79 @@ const ( "schemaVersion": 35 }` + dashboardWithDuplicateDatasources = ` +{ + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "abc123" + }, + "id": 1, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "abc123" + }, + "exemplar": true, + "expr": "go_goroutines{job=\"$job\"}", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "title": "Panel Title", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "_yxMP8Ynk" + }, + "id": 2, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "_yxMP8Ynk" + }, + "exemplar": true, + "expr": "go_goroutines{job=\"$job\"}", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "title": "Panel Title", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "_yxMP8Ynk" + }, + "id": 3, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "_yxMP8Ynk" + }, + "exemplar": true, + "expr": "go_goroutines{job=\"$job\"}", + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "title": "Panel Title", + "type": "timeseries" + } + ], + "schemaVersion": 35 +}` + oldStyleDashboard = ` { "panels": [ @@ -79,12 +152,32 @@ const ( }` ) -func TestGetQueriesFromDashboard(t *testing.T) { +func TestGetUniqueDashboardDatasourceUids(t *testing.T) { + t.Run("can get unique datasource ids from dashboard", func(t *testing.T) { + json, err := simplejson.NewJson([]byte(dashboardWithDuplicateDatasources)) + require.NoError(t, err) + + uids := GetUniqueDashboardDatasourceUids(json) + require.Len(t, uids, 2) + require.Equal(t, "abc123", uids[0]) + require.Equal(t, "_yxMP8Ynk", uids[1]) + }) + + t.Run("can get no datasource uids from empty dashboard", func(t *testing.T) { + json, err := simplejson.NewJson([]byte(`{"panels": {}}`)) + require.NoError(t, err) + + uids := GetUniqueDashboardDatasourceUids(json) + require.Len(t, uids, 0) + }) +} + +func TestGroupQueriesByPanelId(t *testing.T) { t.Run("can extract no queries from empty dashboard", func(t *testing.T) { json, err := simplejson.NewJson([]byte(`{"panels": {}}`)) require.NoError(t, err) - queries := GetQueriesFromDashboard(json) + queries := GroupQueriesByPanelId(json) require.Len(t, queries, 0) }) @@ -92,7 +185,7 @@ func TestGetQueriesFromDashboard(t *testing.T) { json, err := simplejson.NewJson([]byte(dashboardWithNoQueries)) require.NoError(t, err) - queries := GetQueriesFromDashboard(json) + queries := GroupQueriesByPanelId(json) require.Len(t, queries, 1) require.Contains(t, queries, int64(2)) require.Len(t, queries[2], 0) @@ -102,7 +195,7 @@ func TestGetQueriesFromDashboard(t *testing.T) { json, err := simplejson.NewJson([]byte(dashboardWithQueries)) require.NoError(t, err) - queries := GetQueriesFromDashboard(json) + queries := GroupQueriesByPanelId(json) require.Len(t, queries, 1) require.Contains(t, queries, int64(2)) require.Len(t, queries[2], 2) @@ -138,7 +231,7 @@ func TestGetQueriesFromDashboard(t *testing.T) { json, err := simplejson.NewJson([]byte(oldStyleDashboard)) require.NoError(t, err) - queries := GetQueriesFromDashboard(json) + queries := GroupQueriesByPanelId(json) require.Len(t, queries, 1) require.Contains(t, queries, int64(2)) require.Len(t, queries[2], 1) diff --git a/pkg/server/wire.go b/pkg/server/wire.go index c53b0bf19b0..8c3aafa4930 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -77,6 +77,10 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsettings" pluginSettings "github.com/grafana/grafana/pkg/services/pluginsettings/service" "github.com/grafana/grafana/pkg/services/preference/prefimpl" + "github.com/grafana/grafana/pkg/services/publicdashboards" + publicdashboardsApi "github.com/grafana/grafana/pkg/services/publicdashboards/api" + publicdashboardsStore "github.com/grafana/grafana/pkg/services/publicdashboards/database" + publicdashboardsService "github.com/grafana/grafana/pkg/services/publicdashboards/service" "github.com/grafana/grafana/pkg/services/query" "github.com/grafana/grafana/pkg/services/queryhistory" "github.com/grafana/grafana/pkg/services/quota" @@ -275,6 +279,11 @@ var wireBasicSet = wire.NewSet( wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), starimpl.ProvideService, dashverimpl.ProvideService, + publicdashboardsService.ProvideService, + wire.Bind(new(publicdashboards.Service), new(*publicdashboardsService.PublicDashboardServiceImpl)), + publicdashboardsStore.ProvideStore, + wire.Bind(new(publicdashboards.Store), new(*publicdashboardsStore.PublicDashboardStoreImpl)), + publicdashboardsApi.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, ) diff --git a/pkg/services/accesscontrol/database/database.go b/pkg/services/accesscontrol/database/database.go index c89b0801341..9e4d02f4b92 100644 --- a/pkg/services/accesscontrol/database/database.go +++ b/pkg/services/accesscontrol/database/database.go @@ -2,6 +2,7 @@ package database import ( "context" + "strconv" "strings" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -110,3 +111,46 @@ func deletePermissions(sess *sqlstore.DBSession, ids []int64) error { return nil } + +func (s *AccessControlStore) DeleteUserPermissions(ctx context.Context, userID int64) error { + err := s.sql.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + // Delete user role assignments + if _, err := sess.Exec("DELETE FROM user_role WHERE user_id = ?", userID); err != nil { + return err + } + + // Delete permissions that are scoped to user + if _, err := sess.Exec("DELETE FROM permission WHERE scope = ?", accesscontrol.Scope("users", "id", strconv.FormatInt(userID, 10))); err != nil { + return err + } + + var roleIDs []int64 + if err := sess.SQL("SELECT id FROM role WHERE name = ?", accesscontrol.ManagedUserRoleName(userID)).Find(&roleIDs); err != nil { + return err + } + + if len(roleIDs) == 0 { + return nil + } + + query := "DELETE FROM permission WHERE role_id IN(? " + strings.Repeat(",?", len(roleIDs)-1) + ")" + args := make([]interface{}, 0, len(roleIDs)+1) + args = append(args, query) + for _, id := range roleIDs { + args = append(args, id) + } + + // Delete managed user permissions + if _, err := sess.Exec(args...); err != nil { + return err + } + + // Delete managed user roles + if _, err := sess.Exec("DELETE FROM role WHERE name = ?", accesscontrol.ManagedUserRoleName(userID)); 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 094f27fd01c..1531523dc67 100644 --- a/pkg/services/accesscontrol/database/database_test.go +++ b/pkg/services/accesscontrol/database/database_test.go @@ -131,6 +131,31 @@ func TestAccessControlStore_GetUserPermissions(t *testing.T) { } } +func TestAccessControlStore_DeleteUserPermissions(t *testing.T) { + store, sql := setupTestEnv(t) + + user, _ := createUserAndTeam(t, sql, 1) + + _, err := store.SetUserResourcePermission(context.Background(), 1, accesscontrol.User{ID: user.ID}, types.SetResourcePermissionCommand{ + Actions: []string{"dashboards:write"}, + Resource: "dashboards", + ResourceID: "1", + }, nil) + require.NoError(t, err) + + err = store.DeleteUserPermissions(context.Background(), user.ID) + require.NoError(t, err) + + permissions, err := store.GetUserPermissions(context.Background(), accesscontrol.GetUserPermissionsQuery{ + OrgID: 1, + UserID: user.ID, + Roles: []string{"Admin"}, + Actions: []string{"dashboards:write"}, + }) + require.NoError(t, err) + assert.Len(t, permissions, 0) +} + func createUserAndTeam(t *testing.T, sql *sqlstore.SQLStore, orgID int64) (*user.User, models.Team) { t.Helper() diff --git a/pkg/services/accesscontrol/database/resource_permissions.go b/pkg/services/accesscontrol/database/resource_permissions.go index ab34e1c63f3..0a39bb2663a 100644 --- a/pkg/services/accesscontrol/database/resource_permissions.go +++ b/pkg/services/accesscontrol/database/resource_permissions.go @@ -9,23 +9,25 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions/types" + "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/sqlstore" ) type flatResourcePermission struct { - ID int64 `xorm:"id"` - RoleName string - Action string - Scope string - UserId int64 - UserLogin string - UserEmail string - TeamId int64 - TeamEmail string - Team string - BuiltInRole string - Created time.Time - Updated time.Time + ID int64 `xorm:"id"` + RoleName string + Action string + Scope string + UserId int64 + UserLogin string + UserEmail string + UserIsServiceAccount bool + TeamId int64 + TeamEmail string + Team string + BuiltInRole string + Created time.Time + Updated time.Time } func (p *flatResourcePermission) IsManaged(scope string) bool { @@ -292,6 +294,7 @@ func (s *AccessControlStore) getResourcePermissions(sess *sqlstore.DBSession, or ur.user_id AS user_id, u.login AS user_login, u.email AS user_email, + u.is_service_account AS user_is_service_account, 0 AS team_id, '' AS team, '' AS team_email, @@ -302,6 +305,7 @@ func (s *AccessControlStore) getResourcePermissions(sess *sqlstore.DBSession, or 0 AS user_id, '' AS user_login, '' AS user_email, + false AS user_is_service_account, tr.team_id AS team_id, t.name AS team, t.email AS team_email, @@ -312,6 +316,7 @@ func (s *AccessControlStore) getResourcePermissions(sess *sqlstore.DBSession, or 0 AS user_id, '' AS user_login, '' AS user_email, + false AS user_is_service_account, 0 as team_id, '' AS team, '' AS team_email, @@ -370,8 +375,13 @@ func (s *AccessControlStore) getResourcePermissions(sess *sqlstore.DBSession, or if err != nil { return nil, err } - user := userSelect + userFrom + where + " AND " + userFilter.Where + serviceAccountFilter, err := accesscontrol.Filter(query.User, "u.id", "serviceaccounts:id:", serviceaccounts.ActionRead) + if err != nil { + return nil, err + } + user := userSelect + userFrom + where + " AND (" + userFilter.Where + " OR " + serviceAccountFilter.Where + ") " args = append(args, userFilter.Args...) + args = append(args, serviceAccountFilter.Args...) teamFilter, err := accesscontrol.Filter(query.User, "t.id", "teams:id:", accesscontrol.ActionTeamsRead) if err != nil { @@ -457,20 +467,21 @@ func flatPermissionsToResourcePermission(scope string, permissions []flatResourc first := permissions[0] return &accesscontrol.ResourcePermission{ - ID: first.ID, - RoleName: first.RoleName, - Actions: actions, - Scope: first.Scope, - UserId: first.UserId, - UserLogin: first.UserLogin, - UserEmail: first.UserEmail, - TeamId: first.TeamId, - TeamEmail: first.TeamEmail, - Team: first.Team, - BuiltInRole: first.BuiltInRole, - Created: first.Created, - Updated: first.Updated, - IsManaged: first.IsManaged(scope), + ID: first.ID, + RoleName: first.RoleName, + Actions: actions, + Scope: first.Scope, + UserId: first.UserId, + UserLogin: first.UserLogin, + UserEmail: first.UserEmail, + UserIsServiceAccount: first.UserIsServiceAccount, + TeamId: first.TeamId, + TeamEmail: first.TeamEmail, + Team: first.Team, + BuiltInRole: first.BuiltInRole, + Created: first.Created, + Updated: first.Updated, + IsManaged: first.IsManaged(scope), } } @@ -581,6 +592,7 @@ func (s *AccessControlStore) getResourcePermissionsByIds(sess *sqlstore.DBSessio ur.user_id AS user_id, u.login AS user_login, u.email AS user_email, + u.is_service_account AS user_is_service_account, tr.team_id AS team_id, t.name AS team, t.email AS team_email, diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 0317ea73b52..85ef4f1188b 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -222,20 +222,21 @@ type ScopeParams struct { // ResourcePermission is structure that holds all actions that either a team / user / builtin-role // can perform against specific resource. type ResourcePermission struct { - ID int64 - RoleName string - Actions []string - Scope string - UserId int64 - UserLogin string - UserEmail string - TeamId int64 - TeamEmail string - Team string - BuiltInRole string - IsManaged bool - Created time.Time - Updated time.Time + ID int64 + RoleName string + Actions []string + Scope string + UserId int64 + UserLogin string + UserEmail string + UserIsServiceAccount bool + TeamId int64 + TeamEmail string + Team string + BuiltInRole string + IsManaged bool + Created time.Time + Updated time.Time } func (p *ResourcePermission) Contains(targetActions []string) bool { diff --git a/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go index a271c3bbd6f..de8da4c50b4 100644 --- a/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go +++ b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go @@ -58,9 +58,10 @@ func ProvideTeamPermissions( return nil }, Assignments: resourcepermissions.Assignments{ - Users: true, - Teams: false, - BuiltInRoles: false, + Users: true, + Teams: false, + BuiltInRoles: false, + ServiceAccounts: true, }, PermissionsToActions: map[string][]string{ "Member": TeamMemberActions, @@ -149,9 +150,10 @@ func ProvideDashboardPermissions( return []string{}, nil }, Assignments: resourcepermissions.Assignments{ - Users: true, - Teams: true, - BuiltInRoles: true, + Users: true, + Teams: true, + BuiltInRoles: true, + ServiceAccounts: false, }, PermissionsToActions: map[string][]string{ "View": DashboardViewActions, @@ -206,9 +208,10 @@ func ProvideFolderPermissions( return nil }, Assignments: resourcepermissions.Assignments{ - Users: true, - Teams: true, - BuiltInRoles: true, + Users: true, + Teams: true, + BuiltInRoles: true, + ServiceAccounts: false, }, PermissionsToActions: map[string][]string{ "View": append(DashboardViewActions, FolderViewActions...), diff --git a/pkg/services/accesscontrol/resourcepermissions/api.go b/pkg/services/accesscontrol/resourcepermissions/api.go index a1892130cfd..c4d8d963ef8 100644 --- a/pkg/services/accesscontrol/resourcepermissions/api.go +++ b/pkg/services/accesscontrol/resourcepermissions/api.go @@ -40,7 +40,7 @@ func (a *api) registerEndpoints() { scope := accesscontrol.Scope(a.service.options.Resource, a.service.options.ResourceAttribute, accesscontrol.Parameter(":resourceID")) r.Get("/description", auth(disable, accesscontrol.EvalPermission(actionRead)), routing.Wrap(a.getDescription)) r.Get("/:resourceID", inheritanceSolver, auth(disable, accesscontrol.EvalPermission(actionRead, scope)), routing.Wrap(a.getPermissions)) - if a.service.options.Assignments.Users { + if a.service.options.Assignments.Users || a.service.options.Assignments.ServiceAccounts { r.Post("/:resourceID/users/:userID", inheritanceSolver, auth(disable, accesscontrol.EvalPermission(actionWrite, scope)), routing.Wrap(a.setUserPermission)) } if a.service.options.Assignments.Teams { @@ -53,9 +53,10 @@ func (a *api) registerEndpoints() { } type Assignments struct { - Users bool `json:"users"` - Teams bool `json:"teams"` - BuiltInRoles bool `json:"builtInRoles"` + Users bool `json:"users"` + Teams bool `json:"teams"` + BuiltInRoles bool `json:"builtInRoles"` + ServiceAccounts bool `json:"serviceAccounts"` } type Description struct { @@ -71,18 +72,19 @@ func (a *api) getDescription(c *models.ReqContext) response.Response { } type resourcePermissionDTO struct { - ID int64 `json:"id"` - RoleName string `json:"roleName"` - IsManaged bool `json:"isManaged"` - UserID int64 `json:"userId,omitempty"` - UserLogin string `json:"userLogin,omitempty"` - UserAvatarUrl string `json:"userAvatarUrl,omitempty"` - Team string `json:"team,omitempty"` - TeamID int64 `json:"teamId,omitempty"` - TeamAvatarUrl string `json:"teamAvatarUrl,omitempty"` - BuiltInRole string `json:"builtInRole,omitempty"` - Actions []string `json:"actions"` - Permission string `json:"permission"` + ID int64 `json:"id"` + RoleName string `json:"roleName"` + IsManaged bool `json:"isManaged"` + UserID int64 `json:"userId,omitempty"` + UserLogin string `json:"userLogin,omitempty"` + UserAvatarUrl string `json:"userAvatarUrl,omitempty"` + UserIsServiceAccount bool `json:"userIsServiceAccount,omitempty"` + Team string `json:"team,omitempty"` + TeamID int64 `json:"teamId,omitempty"` + TeamAvatarUrl string `json:"teamAvatarUrl,omitempty"` + BuiltInRole string `json:"builtInRole,omitempty"` + Actions []string `json:"actions"` + Permission string `json:"permission"` } func (a *api) getPermissions(c *models.ReqContext) response.Response { @@ -110,18 +112,19 @@ func (a *api) getPermissions(c *models.ReqContext) response.Response { } dto = append(dto, resourcePermissionDTO{ - ID: p.ID, - RoleName: p.RoleName, - UserID: p.UserId, - UserLogin: p.UserLogin, - UserAvatarUrl: dtos.GetGravatarUrl(p.UserEmail), - Team: p.Team, - TeamID: p.TeamId, - TeamAvatarUrl: teamAvatarUrl, - BuiltInRole: p.BuiltInRole, - Actions: p.Actions, - Permission: permission, - IsManaged: p.IsManaged, + ID: p.ID, + RoleName: p.RoleName, + UserID: p.UserId, + UserLogin: p.UserLogin, + UserAvatarUrl: dtos.GetGravatarUrl(p.UserEmail), + UserIsServiceAccount: p.UserIsServiceAccount, + Team: p.Team, + TeamID: p.TeamId, + TeamAvatarUrl: teamAvatarUrl, + BuiltInRole: p.BuiltInRole, + Actions: p.Actions, + Permission: permission, + IsManaged: p.IsManaged, }) } } diff --git a/pkg/services/accesscontrol/resourcepermissions/api_test.go b/pkg/services/accesscontrol/resourcepermissions/api_test.go index 2f32ae41b81..2178aa8d0fe 100644 --- a/pkg/services/accesscontrol/resourcepermissions/api_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/api_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -159,12 +160,12 @@ func TestApi_getPermissions(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - service, sql := setupTestEnvironment(t, tt.permissions, testOptions) + service, sql := setupTestEnvironment(t, tt.permissions, testOptionsDashboards) server := setupTestServer(t, &models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}}, service) seedPermissions(t, tt.resourceID, sql, service) - permissions, recorder := getPermission(t, server, testOptions.Resource, tt.resourceID) + permissions, recorder := getPermission(t, server, testOptionsDashboards.Resource, tt.resourceID) assert.Equal(t, tt.expectedStatus, recorder.Code) if tt.expectedStatus == http.StatusOK { @@ -236,14 +237,14 @@ func TestApi_setBuiltinRolePermission(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - service, _ := setupTestEnvironment(t, tt.permissions, testOptions) + service, _ := setupTestEnvironment(t, tt.permissions, testOptionsDashboards) server := setupTestServer(t, &models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}}, service) - recorder := setPermission(t, server, testOptions.Resource, tt.resourceID, tt.permission, "builtInRoles", tt.builtInRole) + recorder := setPermission(t, server, testOptionsDashboards.Resource, tt.resourceID, tt.permission, "builtInRoles", tt.builtInRole) assert.Equal(t, tt.expectedStatus, recorder.Code) if tt.expectedStatus == http.StatusOK { - permissions, _ := getPermission(t, server, testOptions.Resource, tt.resourceID) + permissions, _ := getPermission(t, server, testOptionsDashboards.Resource, tt.resourceID) require.Len(t, permissions, 1) assert.Equal(t, tt.permission, permissions[0].Permission) assert.Equal(t, tt.builtInRole, permissions[0].BuiltInRole) @@ -314,19 +315,19 @@ func TestApi_setTeamPermission(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - service, sql := setupTestEnvironment(t, tt.permissions, testOptions) + service, sql := setupTestEnvironment(t, tt.permissions, testOptionsDashboards) server := setupTestServer(t, &models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}}, service) // seed team _, err := sql.CreateTeam("test", "test@test.com", 1) require.NoError(t, err) - recorder := setPermission(t, server, testOptions.Resource, tt.resourceID, tt.permission, "teams", strconv.Itoa(int(tt.teamID))) + recorder := setPermission(t, server, testOptionsDashboards.Resource, tt.resourceID, tt.permission, "teams", strconv.Itoa(int(tt.teamID))) assert.Equal(t, tt.expectedStatus, recorder.Code) assert.Equal(t, tt.expectedStatus, recorder.Code) if tt.expectedStatus == http.StatusOK { - permissions, _ := getPermission(t, server, testOptions.Resource, tt.resourceID) + permissions, _ := getPermission(t, server, testOptionsDashboards.Resource, tt.resourceID) require.Len(t, permissions, 1) assert.Equal(t, tt.permission, permissions[0].Permission) assert.Equal(t, tt.teamID, permissions[0].TeamID) @@ -397,19 +398,18 @@ func TestApi_setUserPermission(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { - service, sql := setupTestEnvironment(t, tt.permissions, testOptions) + service, sql := setupTestEnvironment(t, tt.permissions, testOptionsDashboards) server := setupTestServer(t, &models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}}, service) // seed user _, err := sql.CreateUser(context.Background(), user.CreateUserCommand{Login: "test", OrgID: 1}) require.NoError(t, err) - recorder := setPermission(t, server, testOptions.Resource, tt.resourceID, tt.permission, "users", strconv.Itoa(int(tt.userID))) + recorder := setPermission(t, server, testOptionsDashboards.Resource, tt.resourceID, tt.permission, "users", strconv.Itoa(int(tt.userID))) assert.Equal(t, tt.expectedStatus, recorder.Code) - assert.Equal(t, tt.expectedStatus, recorder.Code) if tt.expectedStatus == http.StatusOK { - permissions, _ := getPermission(t, server, testOptions.Resource, tt.resourceID) + permissions, _ := getPermission(t, server, testOptionsDashboards.Resource, tt.resourceID) require.Len(t, permissions, 1) assert.Equal(t, tt.permission, permissions[0].Permission) assert.Equal(t, tt.userID, permissions[0].UserID) @@ -418,6 +418,92 @@ func TestApi_setUserPermission(t *testing.T) { } } +type setServiceAccountPermissionTestCase struct { + desc string + serviceaccountID int64 + resourceID string + expectedStatus int + permission string + permissions []accesscontrol.Permission +} + +func TestApi_setServiceAccountPermission(t *testing.T) { + tests := []setServiceAccountPermissionTestCase{ + { + desc: "should set Edit permission for serviceaccount 1", + serviceaccountID: 1, + resourceID: "1", + expectedStatus: 200, + permission: "Edit", + permissions: []accesscontrol.Permission{ + {Action: "teams.permissions:read", Scope: "teams:id:1"}, + {Action: "teams.permissions:write", Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, + {Action: serviceaccounts.ActionRead, Scope: serviceaccounts.ScopeAll}, + }, + }, + { + desc: "should set View permission for serviceaccount 1", + serviceaccountID: 1, + resourceID: "1", + expectedStatus: 200, + permission: "View", + permissions: []accesscontrol.Permission{ + {Action: "teams.permissions:read", Scope: "teams:id:1"}, + {Action: "teams.permissions:write", Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, + {Action: serviceaccounts.ActionRead, Scope: serviceaccounts.ScopeAll}, + }, + }, + { + desc: "should set return http 400 when serviceaccount does not exist", + serviceaccountID: 2, + resourceID: "1", + expectedStatus: http.StatusBadRequest, + permission: "View", + permissions: []accesscontrol.Permission{ + {Action: "teams.permissions:read", Scope: "teams:id:1"}, + {Action: "teams.permissions:write", Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, + {Action: serviceaccounts.ActionRead, Scope: serviceaccounts.ScopeAll}, + }, + }, + { + desc: "should return http 403 when missing permissions", + serviceaccountID: 1, + resourceID: "1", + expectedStatus: http.StatusForbidden, + permission: "View", + permissions: []accesscontrol.Permission{ + {Action: "teams.permissions:read", Scope: "teams:id:1"}, + {Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + service, sql := setupTestEnvironment(t, tt.permissions, testOptionsTeams) + server := setupTestServer(t, &models.SignedInUser{OrgId: 1, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}}, service) + + // seed serviceaccount + _, err := sql.CreateUser(context.Background(), user.CreateUserCommand{Login: "test", OrgID: 1, IsServiceAccount: true}) + require.NoError(t, err) + + recorder := setPermission(t, server, testOptionsTeams.Resource, tt.resourceID, tt.permission, "users", strconv.Itoa(int(tt.serviceaccountID))) + assert.Equal(t, tt.expectedStatus, recorder.Code) + + if tt.expectedStatus == http.StatusOK { + permissions, _ := getPermission(t, server, testOptionsTeams.Resource, tt.resourceID) + require.Len(t, permissions, 1) + assert.Equal(t, tt.permission, permissions[0].Permission) + assert.Equal(t, tt.serviceaccountID, permissions[0].UserID) + assert.Equal(t, true, permissions[0].UserIsServiceAccount) + } + }) + } +} + func setupTestServer(t *testing.T, user *models.SignedInUser, service *Service) *web.Mux { server := web.New() server.UseMiddleware(web.Renderer(path.Join(setting.StaticRootPath, "views"), "[[", "]]")) @@ -444,13 +530,14 @@ func contextProvider(tc *testContext) web.Handler { } } -var testOptions = Options{ +var testOptionsDashboards = Options{ Resource: "dashboards", ResourceAttribute: "id", Assignments: Assignments{ - Users: true, - Teams: true, - BuiltInRoles: true, + Users: true, + Teams: true, + BuiltInRoles: true, + ServiceAccounts: true, }, PermissionsToActions: map[string][]string{ "View": {"dashboards:read"}, @@ -458,6 +545,21 @@ var testOptions = Options{ }, } +var testOptionsTeams = Options{ + Resource: "teams", + ResourceAttribute: "id", + Assignments: Assignments{ + Users: true, + Teams: true, + BuiltInRoles: true, + ServiceAccounts: true, + }, + PermissionsToActions: map[string][]string{ + "View": {"teams:read"}, + "Edit": {"teams:read", "teams:write", "teams:delete"}, + }, +} + func getPermission(t *testing.T, server *web.Mux, resource, resourceID string) ([]resourcePermissionDTO, *httptest.ResponseRecorder) { req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/api/access-control/%s/%s", resource, resourceID), nil) require.NoError(t, err) diff --git a/pkg/services/accesscontrol/resourcepermissions/service.go b/pkg/services/accesscontrol/resourcepermissions/service.go index d8ae4903963..2722dba149a 100644 --- a/pkg/services/accesscontrol/resourcepermissions/service.go +++ b/pkg/services/accesscontrol/resourcepermissions/service.go @@ -275,7 +275,7 @@ func (s *Service) validateResource(ctx context.Context, orgID int64, resourceID } func (s *Service) validateUser(ctx context.Context, orgID, userID int64) error { - if !s.options.Assignments.Users { + if !(s.options.Assignments.Users || s.options.Assignments.ServiceAccounts) { return ErrInvalidAssignment } diff --git a/pkg/services/accesscontrol/resourcepermissions/service_test.go b/pkg/services/accesscontrol/resourcepermissions/service_test.go index 8775502ab99..be8a18c5db8 100644 --- a/pkg/services/accesscontrol/resourcepermissions/service_test.go +++ b/pkg/services/accesscontrol/resourcepermissions/service_test.go @@ -38,7 +38,7 @@ func TestService_SetUserPermission(t *testing.T) { t.Run(tt.desc, func(t *testing.T) { service, sql := setupTestEnvironment(t, []accesscontrol.Permission{}, Options{ Resource: "dashboards", - Assignments: Assignments{Users: true}, + Assignments: Assignments{Users: true, ServiceAccounts: true}, PermissionsToActions: nil, }) @@ -159,9 +159,10 @@ func TestService_SetPermissions(t *testing.T) { options: Options{ Resource: "dashboards", Assignments: Assignments{ - Users: true, - Teams: true, - BuiltInRoles: true, + Users: true, + Teams: true, + BuiltInRoles: true, + ServiceAccounts: true, }, PermissionsToActions: map[string][]string{ "View": {"dashboards:read"}, @@ -178,9 +179,10 @@ func TestService_SetPermissions(t *testing.T) { options: Options{ Resource: "dashboards", Assignments: Assignments{ - Users: true, - Teams: true, - BuiltInRoles: true, + Users: true, + Teams: true, + BuiltInRoles: true, + ServiceAccounts: true, }, PermissionsToActions: map[string][]string{ "View": {"dashboards:read"}, diff --git a/pkg/services/dashboards/dashboard.go b/pkg/services/dashboards/dashboard.go index c47af49680d..b3a1b3cffe3 100644 --- a/pkg/services/dashboards/dashboard.go +++ b/pkg/services/dashboards/dashboard.go @@ -3,14 +3,12 @@ package dashboards import ( "context" - "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/models" ) //go:generate mockery --name DashboardService --structname FakeDashboardService --inpackage --filename dashboard_service_mock.go // DashboardService is a service for operating on dashboards. type DashboardService interface { - BuildPublicDashboardMetricRequest(ctx context.Context, dashboard *models.Dashboard, publicDashboard *models.PublicDashboard, panelId int64) (dtos.MetricRequest, error) BuildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, shouldValidateAlerts bool, validateProvisionedDashboard bool) (*models.SaveDashboardCommand, error) DeleteDashboard(ctx context.Context, dashboardId int64, orgId int64) error FindDashboards(ctx context.Context, query *models.FindPersistedDashboardsQuery) ([]DashboardSearchProjection, error) @@ -19,14 +17,11 @@ type DashboardService interface { GetDashboards(ctx context.Context, query *models.GetDashboardsQuery) error GetDashboardTags(ctx context.Context, query *models.GetDashboardTagsQuery) error GetDashboardUIDById(ctx context.Context, query *models.GetDashboardRefByIdQuery) error - GetPublicDashboard(ctx context.Context, accessToken string) (*models.Dashboard, error) - GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*models.PublicDashboard, error) HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *models.HasAdminPermissionInDashboardsOrFoldersQuery) error HasEditPermissionInFolders(ctx context.Context, query *models.HasEditPermissionInFoldersQuery) error ImportDashboard(ctx context.Context, dto *SaveDashboardDTO) (*models.Dashboard, error) MakeUserAdmin(ctx context.Context, orgID int64, userID, dashboardID int64, setViewAndEditPermissions bool) error SaveDashboard(ctx context.Context, dto *SaveDashboardDTO, allowUiUpdate bool) (*models.Dashboard, error) - SavePublicDashboardConfig(ctx context.Context, dto *SavePublicDashboardConfigDTO) (*models.PublicDashboard, error) SearchDashboards(ctx context.Context, query *models.FindPersistedDashboardsQuery) error UpdateDashboardACL(ctx context.Context, uid int64, items []*models.DashboardAcl) error } @@ -65,18 +60,13 @@ type Store interface { GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) - GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*models.PublicDashboard, error) - GetPublicDashboard(ctx context.Context, accessToken string) (*models.PublicDashboard, *models.Dashboard, error) - GenerateNewPublicDashboardUid(ctx context.Context) (string, error) HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *models.HasAdminPermissionInDashboardsOrFoldersQuery) error HasEditPermissionInFolders(ctx context.Context, query *models.HasEditPermissionInFoldersQuery) error // SaveAlerts saves dashboard alerts. SaveAlerts(ctx context.Context, dashID int64, alerts []*models.Alert) error SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error) SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) - SavePublicDashboardConfig(ctx context.Context, cmd models.SavePublicDashboardConfigCommand) (*models.PublicDashboard, error) UnprovisionDashboard(ctx context.Context, id int64) error - UpdatePublicDashboardConfig(ctx context.Context, cmd models.SavePublicDashboardConfigCommand) error UpdateDashboardACL(ctx context.Context, uid int64, items []*models.DashboardAcl) error // ValidateDashboardBeforeSave validates a dashboard before save. ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (bool, error) diff --git a/pkg/services/dashboards/dashboard_service_mock.go b/pkg/services/dashboards/dashboard_service_mock.go index 7f46f43096c..8e42d531998 100644 --- a/pkg/services/dashboards/dashboard_service_mock.go +++ b/pkg/services/dashboards/dashboard_service_mock.go @@ -1,14 +1,12 @@ -// Code generated by mockery v2.12.1. DO NOT EDIT. +// Code generated by mockery v2.12.2. DO NOT EDIT. package dashboards import ( context "context" - dtos "github.com/grafana/grafana/pkg/api/dtos" - mock "github.com/stretchr/testify/mock" - models "github.com/grafana/grafana/pkg/models" + mock "github.com/stretchr/testify/mock" testing "testing" ) @@ -18,27 +16,6 @@ type FakeDashboardService struct { mock.Mock } -// BuildPublicDashboardMetricRequest provides a mock function with given fields: ctx, dashboard, publicDashboard, panelId -func (_m *FakeDashboardService) BuildPublicDashboardMetricRequest(ctx context.Context, dashboard *models.Dashboard, publicDashboard *models.PublicDashboard, panelId int64) (dtos.MetricRequest, error) { - ret := _m.Called(ctx, dashboard, publicDashboard, panelId) - - var r0 dtos.MetricRequest - if rf, ok := ret.Get(0).(func(context.Context, *models.Dashboard, *models.PublicDashboard, int64) dtos.MetricRequest); ok { - r0 = rf(ctx, dashboard, publicDashboard, panelId) - } else { - r0 = ret.Get(0).(dtos.MetricRequest) - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, *models.Dashboard, *models.PublicDashboard, int64) error); ok { - r1 = rf(ctx, dashboard, publicDashboard, panelId) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // 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) (*models.SaveDashboardCommand, error) { ret := _m.Called(ctx, dto, shouldValidateAlerts, validateProvisionedDashboard) @@ -169,52 +146,6 @@ func (_m *FakeDashboardService) GetDashboards(ctx context.Context, query *models return r0 } -// GetPublicDashboard provides a mock function with given fields: ctx, accessToken -func (_m *FakeDashboardService) GetPublicDashboard(ctx context.Context, accessToken string) (*models.Dashboard, error) { - ret := _m.Called(ctx, accessToken) - - var r0 *models.Dashboard - if rf, ok := ret.Get(0).(func(context.Context, string) *models.Dashboard); ok { - r0 = rf(ctx, accessToken) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*models.Dashboard) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { - r1 = rf(ctx, accessToken) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// GetPublicDashboardConfig provides a mock function with given fields: ctx, orgId, dashboardUid -func (_m *FakeDashboardService) GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*models.PublicDashboard, error) { - ret := _m.Called(ctx, orgId, dashboardUid) - - var r0 *models.PublicDashboard - 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) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, int64, string) error); ok { - r1 = rf(ctx, orgId, dashboardUid) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // HasAdminPermissionInDashboardsOrFolders provides a mock function with given fields: ctx, query func (_m *FakeDashboardService) HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *models.HasAdminPermissionInDashboardsOrFoldersQuery) error { ret := _m.Called(ctx, query) @@ -303,29 +234,6 @@ func (_m *FakeDashboardService) SaveDashboard(ctx context.Context, dto *SaveDash return r0, r1 } -// SavePublicDashboardConfig provides a mock function with given fields: ctx, dto -func (_m *FakeDashboardService) SavePublicDashboardConfig(ctx context.Context, dto *SavePublicDashboardConfigDTO) (*models.PublicDashboard, error) { - ret := _m.Called(ctx, dto) - - var r0 *models.PublicDashboard - if rf, ok := ret.Get(0).(func(context.Context, *SavePublicDashboardConfigDTO) *models.PublicDashboard); ok { - r0 = rf(ctx, dto) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*models.PublicDashboard) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, *SavePublicDashboardConfigDTO) error); ok { - r1 = rf(ctx, dto) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // SearchDashboards provides a mock function with given fields: ctx, query func (_m *FakeDashboardService) SearchDashboards(ctx context.Context, query *models.FindPersistedDashboardsQuery) error { ret := _m.Called(ctx, query) diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 2fedc4abf01..f2f3db9c2ac 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -15,13 +15,6 @@ type SaveDashboardDTO struct { Dashboard *models.Dashboard } -type SavePublicDashboardConfigDTO struct { - DashboardUid string - OrgId int64 - UserId int64 - PublicDashboard *models.PublicDashboard -} - type DashboardSearchProjection struct { ID int64 `xorm:"id"` UID string `xorm:"uid"` diff --git a/pkg/services/dashboards/service/dashboard_public.go b/pkg/services/dashboards/service/dashboard_public.go deleted file mode 100644 index 3da5b9e2f31..00000000000 --- a/pkg/services/dashboards/service/dashboard_public.go +++ /dev/null @@ -1,150 +0,0 @@ -package service - -import ( - "context" - "fmt" - "time" - - "github.com/google/uuid" - "github.com/grafana/grafana/pkg/api/dtos" - "github.com/grafana/grafana/pkg/components/simplejson" - "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/dashboards" -) - -// Gets public dashboard via access token -func (dr *DashboardServiceImpl) GetPublicDashboard(ctx context.Context, accessToken string) (*models.Dashboard, error) { - pubdash, d, err := dr.dashboardStore.GetPublicDashboard(ctx, accessToken) - - if err != nil { - return nil, err - } - - if pubdash == nil || d == nil { - return nil, dashboards.ErrPublicDashboardNotFound - } - - if !pubdash.IsEnabled { - return nil, dashboards.ErrPublicDashboardNotFound - } - - ts := pubdash.BuildTimeSettings(d) - d.Data.SetPath([]string{"time", "from"}, ts.From) - d.Data.SetPath([]string{"time", "to"}, ts.To) - - return d, nil -} - -// GetPublicDashboardConfig is a helper method to retrieve the public dashboard configuration for a given dashboard from the database -func (dr *DashboardServiceImpl) GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*models.PublicDashboard, error) { - pdc, err := dr.dashboardStore.GetPublicDashboardConfig(ctx, orgId, dashboardUid) - if err != nil { - return nil, err - } - - return pdc, nil -} - -// SavePublicDashboardConfig is a helper method to persist the sharing config -// to the database. It handles validations for sharing config and persistence -func (dr *DashboardServiceImpl) SavePublicDashboardConfig(ctx context.Context, dto *dashboards.SavePublicDashboardConfigDTO) (*models.PublicDashboard, error) { - if len(dto.DashboardUid) == 0 { - return nil, dashboards.ErrDashboardIdentifierNotSet - } - - // set default value for time settings - if dto.PublicDashboard.TimeSettings == nil { - dto.PublicDashboard.TimeSettings = simplejson.New() - } - - if dto.PublicDashboard.Uid == "" { - return dr.savePublicDashboardConfig(ctx, dto) - } - - return dr.updatePublicDashboardConfig(ctx, dto) -} - -func (dr *DashboardServiceImpl) savePublicDashboardConfig(ctx context.Context, dto *dashboards.SavePublicDashboardConfigDTO) (*models.PublicDashboard, error) { - uid, err := dr.dashboardStore.GenerateNewPublicDashboardUid(ctx) - if err != nil { - return nil, err - } - - accessToken, err := GenerateAccessToken() - if err != nil { - return nil, err - } - - cmd := models.SavePublicDashboardConfigCommand{ - DashboardUid: dto.DashboardUid, - OrgId: dto.OrgId, - PublicDashboard: models.PublicDashboard{ - Uid: uid, - DashboardUid: dto.DashboardUid, - OrgId: dto.OrgId, - IsEnabled: dto.PublicDashboard.IsEnabled, - TimeSettings: dto.PublicDashboard.TimeSettings, - CreatedBy: dto.UserId, - CreatedAt: time.Now(), - AccessToken: accessToken, - }, - } - - return dr.dashboardStore.SavePublicDashboardConfig(ctx, cmd) -} - -func (dr *DashboardServiceImpl) updatePublicDashboardConfig(ctx context.Context, dto *dashboards.SavePublicDashboardConfigDTO) (*models.PublicDashboard, error) { - cmd := models.SavePublicDashboardConfigCommand{ - PublicDashboard: models.PublicDashboard{ - Uid: dto.PublicDashboard.Uid, - IsEnabled: dto.PublicDashboard.IsEnabled, - TimeSettings: dto.PublicDashboard.TimeSettings, - UpdatedBy: dto.UserId, - UpdatedAt: time.Now(), - }, - } - - err := dr.dashboardStore.UpdatePublicDashboardConfig(ctx, cmd) - if err != nil { - return nil, err - } - - publicDashboard, err := dr.dashboardStore.GetPublicDashboardConfig(ctx, dto.OrgId, dto.DashboardUid) - if err != nil { - return nil, err - } - - return publicDashboard, nil -} - -// BuildPublicDashboardMetricRequest merges public dashboard parameters with -// dashboard and returns a metrics request to be sent to query backend -func (dr *DashboardServiceImpl) BuildPublicDashboardMetricRequest(ctx context.Context, dashboard *models.Dashboard, publicDashboard *models.PublicDashboard, panelId int64) (dtos.MetricRequest, error) { - if !publicDashboard.IsEnabled { - return dtos.MetricRequest{}, dashboards.ErrPublicDashboardNotFound - } - - queriesByPanel := models.GetQueriesFromDashboard(dashboard.Data) - - if _, ok := queriesByPanel[panelId]; !ok { - return dtos.MetricRequest{}, dashboards.ErrPublicDashboardPanelNotFound - } - - ts := publicDashboard.BuildTimeSettings(dashboard) - - return dtos.MetricRequest{ - From: ts.From, - To: ts.To, - Queries: queriesByPanel[panelId], - }, nil -} - -// generates a uuid formatted without dashes to use as access token -func GenerateAccessToken() (string, error) { - token, err := uuid.NewRandom() - if err != nil { - return "", err - } - - return fmt.Sprintf("%x", token[:]), nil -} diff --git a/pkg/services/dashboards/store_mock.go b/pkg/services/dashboards/store_mock.go index 8ecefb4e05c..ddf976aa679 100644 --- a/pkg/services/dashboards/store_mock.go +++ b/pkg/services/dashboards/store_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.12.1. DO NOT EDIT. +// Code generated by mockery v2.12.2. DO NOT EDIT. package dashboards @@ -67,27 +67,6 @@ func (_m *FakeDashboardStore) FindDashboards(ctx context.Context, query *models. return r0, r1 } -// GenerateNewPublicDashboardUid provides a mock function with given fields: ctx -func (_m *FakeDashboardStore) GenerateNewPublicDashboardUid(ctx context.Context) (string, error) { - ret := _m.Called(ctx) - - var r0 string - if rf, ok := ret.Get(0).(func(context.Context) string); ok { - r0 = rf(ctx) - } else { - r0 = ret.Get(0).(string) - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context) error); ok { - r1 = rf(ctx) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // GetDashboard provides a mock function with given fields: ctx, query func (_m *FakeDashboardStore) GetDashboard(ctx context.Context, query *models.GetDashboardQuery) (*models.Dashboard, error) { ret := _m.Called(ctx, query) @@ -319,61 +298,6 @@ func (_m *FakeDashboardStore) GetProvisionedDataByDashboardUID(orgID int64, dash return r0, r1 } -// GetPublicDashboard provides a mock function with given fields: ctx, accessToken -func (_m *FakeDashboardStore) GetPublicDashboard(ctx context.Context, accessToken string) (*models.PublicDashboard, *models.Dashboard, error) { - ret := _m.Called(ctx, accessToken) - - var r0 *models.PublicDashboard - if rf, ok := ret.Get(0).(func(context.Context, string) *models.PublicDashboard); ok { - r0 = rf(ctx, accessToken) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*models.PublicDashboard) - } - } - - var r1 *models.Dashboard - if rf, ok := ret.Get(1).(func(context.Context, string) *models.Dashboard); ok { - r1 = rf(ctx, accessToken) - } else { - if ret.Get(1) != nil { - r1 = ret.Get(1).(*models.Dashboard) - } - } - - var r2 error - if rf, ok := ret.Get(2).(func(context.Context, string) error); ok { - r2 = rf(ctx, accessToken) - } else { - r2 = ret.Error(2) - } - - return r0, r1, r2 -} - -// GetPublicDashboardConfig provides a mock function with given fields: ctx, orgId, dashboardUid -func (_m *FakeDashboardStore) GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*models.PublicDashboard, error) { - ret := _m.Called(ctx, orgId, dashboardUid) - - var r0 *models.PublicDashboard - 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) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, int64, string) error); ok { - r1 = rf(ctx, orgId, dashboardUid) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // HasAdminPermissionInDashboardsOrFolders provides a mock function with given fields: ctx, query func (_m *FakeDashboardStore) HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *models.HasAdminPermissionInDashboardsOrFoldersQuery) error { ret := _m.Called(ctx, query) @@ -462,29 +386,6 @@ func (_m *FakeDashboardStore) SaveProvisionedDashboard(cmd models.SaveDashboardC return r0, r1 } -// SavePublicDashboardConfig provides a mock function with given fields: ctx, cmd -func (_m *FakeDashboardStore) SavePublicDashboardConfig(ctx context.Context, cmd models.SavePublicDashboardConfigCommand) (*models.PublicDashboard, error) { - ret := _m.Called(ctx, cmd) - - var r0 *models.PublicDashboard - if rf, ok := ret.Get(0).(func(context.Context, models.SavePublicDashboardConfigCommand) *models.PublicDashboard); ok { - r0 = rf(ctx, cmd) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*models.PublicDashboard) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, models.SavePublicDashboardConfigCommand) error); ok { - r1 = rf(ctx, cmd) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // UnprovisionDashboard provides a mock function with given fields: ctx, id func (_m *FakeDashboardStore) UnprovisionDashboard(ctx context.Context, id int64) error { ret := _m.Called(ctx, id) @@ -513,20 +414,6 @@ func (_m *FakeDashboardStore) UpdateDashboardACL(ctx context.Context, uid int64, return r0 } -// UpdatePublicDashboardConfig provides a mock function with given fields: ctx, cmd -func (_m *FakeDashboardStore) UpdatePublicDashboardConfig(ctx context.Context, cmd models.SavePublicDashboardConfigCommand) error { - ret := _m.Called(ctx, cmd) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, models.SavePublicDashboardConfigCommand) error); ok { - r0 = rf(ctx, cmd) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // ValidateDashboardBeforeSave provides a mock function with given fields: dashboard, overwrite func (_m *FakeDashboardStore) ValidateDashboardBeforeSave(dashboard *models.Dashboard, overwrite bool) (bool, error) { ret := _m.Called(dashboard, overwrite) diff --git a/pkg/services/datasources/models.go b/pkg/services/datasources/models.go index 616074ce8cd..68b938da257 100644 --- a/pkg/services/datasources/models.go +++ b/pkg/services/datasources/models.go @@ -31,9 +31,9 @@ const ( type DsAccess string type DataSource struct { - Id int64 `json:"id"` - OrgId int64 `json:"orgId"` - Version int `json:"version"` + Id int64 `json:"id,omitempty"` + OrgId int64 `json:"orgId,omitempty"` + Version int `json:"version,omitempty"` Name string `json:"name"` Type string `json:"type"` @@ -54,8 +54,8 @@ type DataSource struct { ReadOnly bool `json:"readOnly"` Uid string `json:"uid"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` + Created time.Time `json:"created,omitempty"` + Updated time.Time `json:"updated,omitempty"` } // AllowedCookies parses the jsondata.keepCookies and returns a list of diff --git a/pkg/services/export/commit_helper.go b/pkg/services/export/commit_helper.go new file mode 100644 index 00000000000..b19749c6a03 --- /dev/null +++ b/pkg/services/export/commit_helper.go @@ -0,0 +1,153 @@ +package export + +import ( + "context" + "fmt" + "io/ioutil" + "os" + "path" + "strings" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/services/sqlstore" + jsoniter "github.com/json-iterator/go" +) + +type commitHelper struct { + ctx context.Context + repo *git.Repository + work *git.Worktree + orgDir string // includes the orgID + workDir string // same as the worktree root + orgID int64 + users map[int64]*userInfo +} + +type commitBody struct { + fpath string // absolute + body []byte + frame *data.Frame +} + +type commitOptions struct { + body []commitBody + when time.Time + userID int64 + comment string +} + +func (ch *commitHelper) initOrg(sql *sqlstore.SQLStore, orgID int64) error { + return sql.WithDbSession(ch.ctx, func(sess *sqlstore.DBSession) error { + sess.Table("user"). + Join("inner", "org_user", "user.id = org_user.user_id"). + Cols("user.*", "org_user.role"). + Where("org_user.org_id = ?", orgID). + Asc("user.id") + + rows := make([]*userInfo, 0) + err := sess.Find(&rows) + if err != nil { + return err + } + + lookup := make(map[int64]*userInfo, len(rows)) + for _, row := range rows { + lookup[row.ID] = row + } + ch.users = lookup + ch.orgID = orgID + return err + }) +} + +func (ch *commitHelper) add(opts commitOptions) error { + for _, b := range opts.body { + if !strings.HasPrefix(b.fpath, ch.orgDir) { + return fmt.Errorf("invalid path, must be within the root folder") + } + + // make sure the parent exists + err := os.MkdirAll(path.Dir(b.fpath), 0750) + if err != nil { + return err + } + + body := b.body + if b.frame != nil { + body, err = jsoniter.ConfigCompatibleWithStandardLibrary.MarshalIndent(b.frame, "", " ") + if err != nil { + return err + } + } + + err = ioutil.WriteFile(b.fpath, body, 0644) + if err != nil { + return err + } + + sub := b.fpath[len(ch.workDir)+1:] + _, err = ch.work.Add(sub) + if err != nil { + status, e2 := ch.work.Status() + if e2 != nil { + return fmt.Errorf("error adding: %s (invalud work status: %s)", sub, e2.Error()) + } + fmt.Printf("STATUS: %+v\n", status) + return fmt.Errorf("unable to add file: %s (%d)", sub, len(b.body)) + } + } + + user, ok := ch.users[opts.userID] + if !ok { + user = &userInfo{ + Name: "admin", + Email: "admin@unknown.org", + } + } + sig := user.getAuthor() + if opts.when.Unix() > 10 { + sig.When = opts.when + } + + copts := &git.CommitOptions{ + Author: &sig, + } + + _, err := ch.work.Commit(opts.comment, copts) + return err +} + +type userInfo struct { + ID int64 `json:"-" xorm:"id"` + Login string `json:"login"` + Email string `json:"email"` + Name string `json:"name"` + Password string `json:"password"` + Salt string `json:"salt"` + Role string `json:"org_role"` // org role + Theme string `json:"-"` // managed in preferences + Created time.Time `json:"-"` // managed in git or external source + Updated time.Time `json:"-"` // managed in git or external source + IsDisabled bool `json:"disabled" xorm:"is_disabled"` + IsServiceAccount bool `json:"serviceAccount" xorm:"is_service_account"` + LastSeenAt time.Time `json:"-" xorm:"last_seen_at"` +} + +func (u *userInfo) getAuthor() object.Signature { + return object.Signature{ + Name: firstRealStringX(u.Name, u.Login, u.Email, "?"), + Email: firstRealStringX(u.Email, u.Login, u.Name, "?"), + } +} + +func firstRealStringX(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "?" +} diff --git a/pkg/services/export/dummy_job.go b/pkg/services/export/dummy_job.go index 029bc8edabb..f892b1cea6b 100644 --- a/pkg/services/export/dummy_job.go +++ b/pkg/services/export/dummy_job.go @@ -1,7 +1,6 @@ package export import ( - "errors" "fmt" "math" "math/rand" @@ -23,10 +22,6 @@ type dummyExportJob struct { } func startDummyExportJob(cfg ExportConfig, broadcaster statusBroadcaster) (Job, error) { - if cfg.Format != "git" { - return nil, errors.New("only git format is supported") - } - job := &dummyExportJob{ logger: log.New("dummy_export_job"), cfg: cfg, diff --git a/pkg/services/export/export_anno.go b/pkg/services/export/export_anno.go new file mode 100644 index 00000000000..4f3d0f96684 --- /dev/null +++ b/pkg/services/export/export_anno.go @@ -0,0 +1,129 @@ +package export + +import ( + "encoding/json" + "fmt" + "path/filepath" + "time" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/services/sqlstore" + jsoniter "github.com/json-iterator/go" +) + +func exportAnnotations(helper *commitHelper, job *gitExportJob) error { + return job.sql.WithDbSession(helper.ctx, func(sess *sqlstore.DBSession) error { + type annoResult struct { + ID int64 `xorm:"id"` + DashboardID int64 `xorm:"dashboard_id"` + PanelID int64 `xorm:"panel_id"` + UserID int64 `xorm:"user_id"` + Text string `xorm:"text"` + Epoch int64 `xorm:"epoch"` + EpochEnd int64 `xorm:"epoch_end"` + Created int64 `xorm:"created"` // not used + Tags string `xorm:"tags"` // JSON Array + } + + type annoEvent struct { + PanelID int64 `json:"panel"` + Text string `json:"text"` + Epoch int64 `json:"epoch"` // dashboard/start+end is really the UID + EpochEnd int64 `json:"epoch_end,omitempty"` + Tags []string + } + + rows := make([]*annoResult, 0) + + sess.Table("annotation"). + Where("org_id = ? AND alert_id = 0", helper.orgID).Asc("epoch") + + err := sess.Find(&rows) + if err != nil { + return err + } + + count := len(rows) + f_ID := data.NewFieldFromFieldType(data.FieldTypeInt64, count) + f_DashboardID := data.NewFieldFromFieldType(data.FieldTypeInt64, count) + f_PanelID := data.NewFieldFromFieldType(data.FieldTypeInt64, count) + f_Epoch := data.NewFieldFromFieldType(data.FieldTypeTime, count) + f_EpochEnd := data.NewFieldFromFieldType(data.FieldTypeNullableTime, count) + f_Text := data.NewFieldFromFieldType(data.FieldTypeString, count) + f_Tags := data.NewFieldFromFieldType(data.FieldTypeJSON, count) + + f_ID.Name = "ID" + f_DashboardID.Name = "DashboardID" + f_PanelID.Name = "PanelID" + f_Epoch.Name = "Epoch" + f_EpochEnd.Name = "EpochEnd" + f_Text.Name = "Text" + f_Tags.Name = "Tags" + + for id, row := range rows { + f_ID.Set(id, row.ID) + f_DashboardID.Set(id, row.DashboardID) + f_PanelID.Set(id, row.PanelID) + f_Epoch.Set(id, time.UnixMilli(row.Epoch)) + if row.Epoch != row.EpochEnd { + f_EpochEnd.SetConcrete(id, time.UnixMilli(row.EpochEnd)) + } + f_Text.Set(id, row.Text) + f_Tags.Set(id, json.RawMessage(row.Tags)) + + // Save a file for each + event := &annoEvent{ + PanelID: row.PanelID, + Text: row.Text, + } + err = json.Unmarshal([]byte(row.Tags), &event.Tags) + if err != nil { + return err + } + fname := fmt.Sprintf("%d", row.Epoch) + if row.Epoch != row.EpochEnd { + fname += "-" + fmt.Sprintf("%d", row.EpochEnd) + } + + err = helper.add(commitOptions{ + body: []commitBody{ + { + fpath: filepath.Join(helper.orgDir, + "annotations", + "dashboard", + fmt.Sprintf("id-%d", row.DashboardID), + fname+".json"), + body: prettyJSON(event), + }, + }, + when: time.UnixMilli(row.Epoch), + comment: fmt.Sprintf("Added annotation (%d)", row.ID), + userID: row.UserID, + }) + if err != nil { + return err + } + } + + frame := data.NewFrame("", f_ID, f_DashboardID, f_PanelID, f_Epoch, f_EpochEnd, f_Text, f_Tags) + js, err := jsoniter.ConfigCompatibleWithStandardLibrary.MarshalIndent(frame, "", " ") + if err != nil { + return err + } + + err = helper.add(commitOptions{ + body: []commitBody{ + { + fpath: filepath.Join(helper.orgDir, "annotations", "annotations.json"), + body: js, // TODO, pretty? + }, + }, + when: time.Now(), + comment: "Exported annotations", + }) + if err != nil { + return err + } + return err + }) +} diff --git a/pkg/services/export/export_auth.go b/pkg/services/export/export_auth.go new file mode 100644 index 00000000000..37da1a92d63 --- /dev/null +++ b/pkg/services/export/export_auth.go @@ -0,0 +1,73 @@ +package export + +import ( + "fmt" + "path" + + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +func dumpAuthTables(helper *commitHelper, job *gitExportJob) error { + return job.sql.WithDbSession(helper.ctx, func(sess *sqlstore.DBSession) error { + commit := commitOptions{ + comment: "auth tables dump", + } + + tables := []string{ + "user", // joined with "org_user" to get the role + "user_role", + "builtin_role", + "api_key", + "team", "team_group", "team_role", "team_member", + "role", + "temp_user", + "user_auth_token", // no org_id... is it temporary? + "permission", + } + + for _, table := range tables { + switch table { + case "permission": + sess.Table(table). + Join("left", "role", "permission.role_id = role.id"). + Cols("permission.*"). + Where("org_id = ?", helper.orgID). + Asc("permission.id") + case "user": + sess.Table(table). + Join("inner", "org_user", "user.id = org_user.user_id"). + Cols("user.*", "org_user.role"). + Where("org_user.org_id = ?", helper.orgID). + Asc("user.id") + case "user_auth_token": + sess.Table(table). + Join("inner", "org_user", "user_auth_token.id = org_user.user_id"). + Cols("user_auth_token.*"). + Where("org_user.org_id = ?", helper.orgID). + Asc("user_auth_token.id") + default: + sess.Table(table).Where("org_id = ?", helper.orgID).Asc("id") + } + + raw, err := sess.QueryInterface() + if err != nil { + return fmt.Errorf("unable to read: %s // %s", table, err.Error()) + } + if len(raw) < 1 { + continue // don't write empty files + } + frame, err := queryResultToDataFrame(raw, frameOpts{ + skip: []string{"org_id", "version", "help_flags1", "theme"}, + }) + if err != nil { + return err + } + frame.Name = table + commit.body = append(commit.body, commitBody{ + fpath: path.Join(helper.orgDir, "auth", "sql.dump", table+".json"), + frame: frame, + }) + } + return helper.add(commit) + }) +} diff --git a/pkg/services/export/export_dashboards.go b/pkg/services/export/export_dashboards.go new file mode 100644 index 00000000000..212c31c1a38 --- /dev/null +++ b/pkg/services/export/export_dashboards.go @@ -0,0 +1,229 @@ +package export + +import ( + "bytes" + "encoding/json" + "fmt" + "path" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" + "github.com/grafana/grafana/pkg/infra/filestorage" + "github.com/grafana/grafana/pkg/services/searchV2/extract" + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +func exportDashboards(helper *commitHelper, job *gitExportJob, lookup dsLookup) error { + alias := make(map[string]string, 100) + ids := make(map[int64]string, 100) + folders := make(map[int64]string, 100) + + // Should root files be at the root or in a subfolder called "general"? + if true { + folders[0] = "general" + } + + rootDir := path.Join(helper.orgDir, "root") + folderStructure := commitOptions{ + when: time.Now(), + comment: "Exported folder structure", + } + + err := job.sql.WithDbSession(helper.ctx, func(sess *sqlstore.DBSession) error { + type dashDataQueryResult struct { + Id int64 + UID string `xorm:"uid"` + IsFolder bool `xorm:"is_folder"` + FolderID int64 `xorm:"folder_id"` + Slug string `xorm:"slug"` + Data []byte + Created time.Time + Updated time.Time + } + + rows := make([]*dashDataQueryResult, 0) + + sess.Table("dashboard"). + Where("org_id = ?", helper.orgID). + Cols("id", "is_folder", "folder_id", "data", "slug", "created", "updated", "uid") + + err := sess.Find(&rows) + if err != nil { + return err + } + + // Process all folders + for _, row := range rows { + if !row.IsFolder { + continue + } + dash, err := extract.ReadDashboard(bytes.NewReader(row.Data), lookup) + if err != nil { + return err + } + + dash.UID = row.UID + slug := cleanFileName(dash.Title) + folder := map[string]string{ + "title": dash.Title, + } + + folderStructure.body = append(folderStructure.body, commitBody{ + fpath: path.Join(rootDir, slug, "__folder.json"), + body: prettyJSON(folder), + }) + + alias[dash.UID] = slug + folders[row.Id] = slug + + if row.Created.Before(folderStructure.when) { + folderStructure.when = row.Created + } + } + + // Now process the dashboards in each folder + for _, row := range rows { + if row.IsFolder { + continue + } + fname := row.Slug + "-dash.json" + fpath, ok := folders[row.FolderID] + if ok { + fpath = path.Join(fpath, fname) + } else { + fpath = fname + } + + alias[row.UID] = fpath + ids[row.Id] = fpath + } + + return err + }) + if err != nil { + return err + } + + err = helper.add(folderStructure) + if err != nil { + return err + } + + err = helper.add(commitOptions{ + body: []commitBody{ + { + fpath: filepath.Join(helper.orgDir, "root-alias.json"), + body: prettyJSON(alias), + }, + { + fpath: filepath.Join(helper.orgDir, "root-ids.json"), + body: prettyJSON(ids), + }, + }, + when: folderStructure.when, + comment: "adding UID alias structure", + }) + if err != nil { + return err + } + + // Now walk the history + err = job.sql.WithDbSession(helper.ctx, func(sess *sqlstore.DBSession) error { + type dashVersionResult struct { + DashId int64 `xorm:"dashboard_id"` + Version int64 `xorm:"version"` + Created time.Time `xorm:"created"` + CreatedBy int64 `xorm:"created_by"` + Message string `xorm:"message"` + Data []byte + } + + rows := make([]*dashVersionResult, 0, len(ids)) + + sess.Table("dashboard_version"). + Join("INNER", "dashboard", "dashboard.id = dashboard_version.dashboard_id"). + Where("org_id = ?", job.orgID). + Cols("dashboard_version.dashboard_id", + "dashboard_version.version", + "dashboard_version.created", + "dashboard_version.created_by", + "dashboard_version.message", + "dashboard_version.data"). + Asc("dashboard_version.created") + + err := sess.Find(&rows) + if err != nil { + return err + } + + count := int64(0) + + // Process all folders (only one level deep!!!) + for _, row := range rows { + fpath, ok := ids[row.DashId] + if !ok { + continue + } + + msg := row.Message + if msg == "" { + msg = fmt.Sprintf("Version: %d", row.Version) + } + + err = helper.add(commitOptions{ + body: []commitBody{ + { + fpath: filepath.Join(rootDir, fpath), + body: cleanDashboardJSON(row.Data), + }, + }, + userID: row.CreatedBy, + when: row.Created, + comment: msg, + }) + if err != nil { + return err + } + + count++ + fmt.Printf("COMMIT: %d // %s (%d)\n", count, fpath, row.Version) + + job.status.Current = count + job.status.Last = fpath + job.status.Changed = time.Now().UnixMilli() + job.broadcaster(job.status) + } + + return nil + }) + + return err +} + +func cleanDashboardJSON(data []byte) []byte { + var dash map[string]interface{} + err := json.Unmarshal(data, &dash) + if err != nil { + return nil + } + delete(dash, "id") + delete(dash, "uid") + delete(dash, "version") + + clean, _ := json.MarshalIndent(dash, "", " ") + return clean +} + +// replace any unsafe file name characters... TODO, but be a standard way to do this cleanly!!! +func cleanFileName(name string) string { + name = strings.ReplaceAll(name, "/", "-") + name = strings.ReplaceAll(name, "\\", "-") + name = strings.ReplaceAll(name, ":", "-") + if err := filestorage.ValidatePath(filestorage.Delimiter + name); err != nil { + randomName, _ := uuid.NewRandom() + return randomName.String() + } + return name +} diff --git a/pkg/services/export/export_ds.go b/pkg/services/export/export_ds.go new file mode 100644 index 00000000000..4dbe53c5553 --- /dev/null +++ b/pkg/services/export/export_ds.go @@ -0,0 +1,72 @@ +package export + +import ( + "fmt" + "path/filepath" + "sort" + + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/searchV2/extract" +) + +type dsLookup func(ref *extract.DataSourceRef) *extract.DataSourceRef + +func exportDataSources(helper *commitHelper, job *gitExportJob) (dsLookup, error) { + cmd := &datasources.GetDataSourcesQuery{ + OrgId: job.orgID, + } + err := job.sql.GetDataSources(helper.ctx, cmd) + if err != nil { + return nil, err + } + + sort.SliceStable(cmd.Result, func(i, j int) bool { + return cmd.Result[i].Created.After(cmd.Result[j].Created) + }) + + byUID := make(map[string]*extract.DataSourceRef, len(cmd.Result)) + byName := make(map[string]*extract.DataSourceRef, len(cmd.Result)) + for _, ds := range cmd.Result { + ref := &extract.DataSourceRef{ + UID: ds.Uid, + Type: ds.Type, + } + byUID[ds.Uid] = ref + byName[ds.Name] = ref + ds.OrgId = 0 + ds.Version = 0 + + err := helper.add(commitOptions{ + body: []commitBody{ + { + fpath: filepath.Join(helper.orgDir, "datasources", fmt.Sprintf("%s-ds.json", ds.Uid)), + body: prettyJSON(ds), + }, + }, + when: ds.Created, + comment: fmt.Sprintf("Add datasource: %s", ds.Name), + }) + if err != nil { + return nil, err + } + } + + // Return the lookup function + return func(ref *extract.DataSourceRef) *extract.DataSourceRef { + if ref == nil || ref.UID == "" { + return &extract.DataSourceRef{ + UID: "default.uid", + Type: "default.type", + } + } + v, ok := byUID[ref.UID] + if ok { + return v + } + v, ok = byName[ref.UID] + if ok { + return v + } + return nil + }, nil +} diff --git a/pkg/services/export/export_snapshots.go b/pkg/services/export/export_snapshots.go new file mode 100644 index 00000000000..e9390cc8b79 --- /dev/null +++ b/pkg/services/export/export_snapshots.go @@ -0,0 +1,43 @@ +package export + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/grafana/grafana/pkg/services/dashboardsnapshots" +) + +func exportSnapshots(helper *commitHelper, job *gitExportJob) error { + cmd := &dashboardsnapshots.GetDashboardSnapshotsQuery{ + OrgId: job.orgID, + Limit: 500000, + SignedInUser: nil, + } + if cmd.SignedInUser == nil { + return fmt.Errorf("snapshots requires an admin user") + } + + err := job.dashboardsnapshotsService.SearchDashboardSnapshots(helper.ctx, cmd) + if err != nil { + return err + } + + if len(cmd.Result) < 1 { + return nil // nothing + } + + gitcmd := commitOptions{ + when: time.Now(), + comment: "Export playlists", + } + + for _, snapshot := range cmd.Result { + gitcmd.body = append(gitcmd.body, commitBody{ + fpath: filepath.Join(helper.orgDir, "snapshot", fmt.Sprintf("%d-snapshot.json", snapshot.Id)), + body: prettyJSON(snapshot), + }) + } + + return helper.add(gitcmd) +} diff --git a/pkg/services/export/export_sys_playlists.go b/pkg/services/export/export_sys_playlists.go new file mode 100644 index 00000000000..83943db9563 --- /dev/null +++ b/pkg/services/export/export_sys_playlists.go @@ -0,0 +1,40 @@ +package export + +import ( + "fmt" + "path/filepath" + "time" + + "github.com/grafana/grafana/pkg/models" +) + +func exportSystemPlaylists(helper *commitHelper, job *gitExportJob) error { + cmd := &models.GetPlaylistsQuery{ + OrgId: job.orgID, + Limit: 500000, + } + err := job.sql.SearchPlaylists(helper.ctx, cmd) + if err != nil { + return err + } + + if len(cmd.Result) < 1 { + return nil // nothing + } + + gitcmd := commitOptions{ + when: time.Now(), + comment: "Export playlists", + } + + for _, playlist := range cmd.Result { + // TODO: fix the playlist API so it returns the json we need :) + + gitcmd.body = append(gitcmd.body, commitBody{ + fpath: filepath.Join(helper.orgDir, "system", "playlists", fmt.Sprintf("%s-playlist.json", playlist.UID)), + body: prettyJSON(playlist), + }) + } + + return helper.add(gitcmd) +} diff --git a/pkg/services/export/export_sys_preferences.go b/pkg/services/export/export_sys_preferences.go new file mode 100644 index 00000000000..926240acc04 --- /dev/null +++ b/pkg/services/export/export_sys_preferences.go @@ -0,0 +1,128 @@ +package export + +import ( + "fmt" + "path" + "path/filepath" + "time" + + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +func exportSystemPreferences(helper *commitHelper, job *gitExportJob) error { + type preferences struct { + UserID int64 `json:"-" xorm:"user_id"` + TeamID int64 `json:"-" xorm:"team_id"` + HomeDashboardID int64 `json:"-" xorm:"home_dashboard_id"` + Updated time.Time `json:"-" xorm:"updated"` + JSONData map[string]interface{} `json:"-" xorm:"json_data"` + + Theme string `json:"theme"` + Locale string `json:"locale"` + Timezone string `json:"timezone"` + WeekStart string `json:"week_start,omitempty"` + HomeDashboard string `json:"home,omitempty" xorm:"uid"` // dashboard + NavBar interface{} `json:"navbar,omitempty"` + QueryHistory interface{} `json:"queryHistory,omitempty"` + } + + prefsDir := path.Join(helper.orgDir, "system", "preferences") + users := make(map[int64]*userInfo, len(helper.users)) + for _, user := range helper.users { + users[user.ID] = user + } + + return job.sql.WithDbSession(helper.ctx, func(sess *sqlstore.DBSession) error { + rows := make([]*preferences, 0) + + sess.Table("preferences"). + Join("LEFT", "dashboard", "dashboard.id = preferences.home_dashboard_id"). + Cols("preferences.*", "dashboard.uid"). + Where("preferences.org_id = ?", helper.orgID) + + err := sess.Find(&rows) + if err != nil { + return err + } + + var comment string + var fpath string + for _, row := range rows { + if row.TeamID > 0 { + fpath = filepath.Join(prefsDir, "team", fmt.Sprintf("%d.json", row.TeamID)) + comment = fmt.Sprintf("Team preferences: %d", row.TeamID) + } else if row.UserID == 0 { + fpath = filepath.Join(prefsDir, "default.json") + comment = "Default preferences" + } else { + user, ok := users[row.UserID] + if ok { + delete(users, row.UserID) + } else { + user = &userInfo{ + Login: fmt.Sprintf("__%d__", row.UserID), + } + } + fpath = filepath.Join(prefsDir, "user", fmt.Sprintf("%s.json", user.Login)) + comment = fmt.Sprintf("User preferences: %s", user.getAuthor().Name) + } + + if row.JSONData != nil { + v, ok := row.JSONData["locale"] + if ok && row.Locale == "" { + s, ok := v.(string) + if ok { + row.Locale = s + } + } + + v, ok = row.JSONData["navbar"] + if ok && row.NavBar == nil { + row.NavBar = v + } + + v, ok = row.JSONData["queryHistory"] + if ok && row.QueryHistory == nil { + row.QueryHistory = v + } + } + + err := helper.add(commitOptions{ + body: []commitBody{ + { + fpath: fpath, + body: prettyJSON(row), + }, + }, + when: row.Updated, + comment: comment, + userID: row.UserID, + }) + if err != nil { + return err + } + } + + // add a file for all useres that may not be in the system + for _, user := range users { + row := preferences{ + Theme: user.Theme, // never set? + } + err := helper.add(commitOptions{ + body: []commitBody{ + { + fpath: filepath.Join(prefsDir, "user", fmt.Sprintf("%s.json", user.Login)), + body: prettyJSON(row), + }, + }, + when: user.Updated, + comment: "user preferences", + userID: row.UserID, + }) + if err != nil { + return err + } + } + return err + }) +} diff --git a/pkg/services/export/export_sys_stars.go b/pkg/services/export/export_sys_stars.go new file mode 100644 index 00000000000..53fd6fea0b9 --- /dev/null +++ b/pkg/services/export/export_sys_stars.go @@ -0,0 +1,65 @@ +package export + +import ( + "fmt" + "path/filepath" + + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +func exportSystemStars(helper *commitHelper, job *gitExportJob) error { + byUser := make(map[int64][]string, 50) + + err := job.sql.WithDbSession(helper.ctx, func(sess *sqlstore.DBSession) error { + type starResult struct { + User int64 `xorm:"user_id"` + UID string `xorm:"uid"` + } + + rows := make([]*starResult, 0) + + sess.Table("star"). + Join("INNER", "dashboard", "dashboard.id = star.dashboard_id"). + Cols("star.user_id", "dashboard.uid"). + Where("dashboard.org_id = ?", helper.orgID) + + err := sess.Find(&rows) + if err != nil { + return err + } + + for _, row := range rows { + stars := append(byUser[row.User], fmt.Sprintf("dashboard/%s", row.UID)) + byUser[row.User] = stars + } + return err + }) + if err != nil { + return err + } + + for userID, stars := range byUser { + user, ok := helper.users[userID] + if !ok { + user = &userInfo{ + Login: fmt.Sprintf("__unknown_%d", userID), + } + } + + err := helper.add(commitOptions{ + body: []commitBody{ + { + fpath: filepath.Join(helper.orgDir, "system", "stars", fmt.Sprintf("%s.json", user.Login)), + body: prettyJSON(stars), + }, + }, + when: user.Updated, + comment: "user preferences", + userID: userID, + }) + if err != nil { + return err + } + } + return nil +} diff --git a/pkg/services/export/frame_helper.go b/pkg/services/export/frame_helper.go new file mode 100644 index 00000000000..1f7a291d8e5 --- /dev/null +++ b/pkg/services/export/frame_helper.go @@ -0,0 +1,138 @@ +package export + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/grafana/grafana-plugin-sdk-go/data" +) + +type fieldInfo struct { + Name string + Conv data.FieldConverter +} + +type frameOpts struct { + schema []fieldInfo + skip []string +} + +func prettyJSON(v interface{}) []byte { + b, _ := json.MarshalIndent(v, "", " ") + return b +} + +func queryResultToDataFrame(rows []map[string]interface{}, opts frameOpts) (*data.Frame, error) { + count := len(rows) + if count < 1 { + return nil, nil // empty frame + } + + schema := opts.schema + if len(schema) < 1 { + skip := make(map[string]bool, len(opts.skip)) + for _, k := range opts.skip { + skip[k] = true + } + + for k, v := range rows[0] { + if skip[k] { + continue + } + field := fieldInfo{ + Name: k, + Conv: data.FieldConverter{ + OutputFieldType: data.FieldTypeFor(v), + }, + } + if field.Conv.OutputFieldType == data.FieldTypeUnknown { + fmt.Printf("UNKNOWN type: %s / %v\n", k, v) + continue + } + + // Don't write passwords to disk for now!!!! + if k == "password" || k == "salt" { + field.Conv.Converter = func(v interface{}) (interface{}, error) { + return fmt.Sprintf("<%s>", k), nil + } + } + + schema = append(schema, field) + } + } + + fields := make([]*data.Field, len(schema)) + for i, s := range schema { + fields[i] = data.NewFieldFromFieldType(s.Conv.OutputFieldType, count) + fields[i].Name = s.Name + } + + var err error + for i, row := range rows { + for j, s := range schema { + v, ok := row[s.Name] + if ok && v != nil { + if s.Conv.Converter != nil { + v, err = s.Conv.Converter(v) + if err != nil { + return nil, fmt.Errorf("converting field: %s // %s", s.Name, err.Error()) + } + } + fields[j].Set(i, v) + } + } + } + + // Fields are in random order + if len(opts.schema) < 1 { + last := []*data.Field{} + frame := data.NewFrame("") + lookup := make(map[string]*data.Field, len(fields)) + for _, f := range fields { + if f.Name == "id" { + frame.Fields = append(frame.Fields, f) // first + continue + } + lookup[f.Name] = f + } + + // First items + for _, name := range []string{"name", "login", "email", "role", "description", "uid"} { + f, ok := lookup[name] + if ok { + frame.Fields = append(frame.Fields, f) // first + delete(lookup, name) + } + } + + // IDs + for k, f := range lookup { + if strings.HasSuffix(k, "_id") { + frame.Fields = append(frame.Fields, f) // first + delete(lookup, k) + } else if strings.HasPrefix(k, "is_") { + last = append(last, f) // first + delete(lookup, k) + } + } + + // Last items + for _, name := range []string{"created", "updated"} { + f, ok := lookup[name] + if ok { + last = append(last, f) // first + delete(lookup, name) + } + } + + // Rest + for _, f := range lookup { + frame.Fields = append(frame.Fields, f) + } + + frame.Fields = append(frame.Fields, last...) + return frame, nil + } + return data.NewFrame("", fields...), nil +} diff --git a/pkg/services/export/git_export_job.go b/pkg/services/export/git_export_job.go new file mode 100644 index 00000000000..21243e05c52 --- /dev/null +++ b/pkg/services/export/git_export_job.go @@ -0,0 +1,205 @@ +package export + +import ( + "context" + "fmt" + "path" + "sync" + "time" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboardsnapshots" + "github.com/grafana/grafana/pkg/services/sqlstore" +) + +var _ Job = new(gitExportJob) + +type gitExportJob struct { + logger log.Logger + sql *sqlstore.SQLStore + dashboardsnapshotsService dashboardsnapshots.Service + orgID int64 + rootDir string + + statusMu sync.Mutex + status ExportStatus + cfg ExportConfig + broadcaster statusBroadcaster +} + +type simpleExporter = func(helper *commitHelper, job *gitExportJob) error + +func startGitExportJob(cfg ExportConfig, sql *sqlstore.SQLStore, dashboardsnapshotsService dashboardsnapshots.Service, rootDir string, orgID int64, broadcaster statusBroadcaster) (Job, error) { + job := &gitExportJob{ + logger: log.New("git_export_job"), + cfg: cfg, + sql: sql, + dashboardsnapshotsService: dashboardsnapshotsService, + orgID: orgID, + rootDir: rootDir, + broadcaster: broadcaster, + status: ExportStatus{ + Running: true, + Target: "git export", + Started: time.Now().UnixMilli(), + Current: 0, + }, + } + + broadcaster(job.status) + go job.start() + return job, nil +} + +func (e *gitExportJob) getStatus() ExportStatus { + e.statusMu.Lock() + defer e.statusMu.Unlock() + + return e.status +} + +func (e *gitExportJob) getConfig() ExportConfig { + e.statusMu.Lock() + defer e.statusMu.Unlock() + + return e.cfg +} + +// Utility function to export dashboards +func (e *gitExportJob) start() { + defer func() { + e.logger.Info("Finished git export job") + + e.statusMu.Lock() + defer e.statusMu.Unlock() + s := e.status + if err := recover(); err != nil { + e.logger.Error("export panic", "error", err) + s.Status = fmt.Sprintf("ERROR: %v", err) + } + // Make sure it finishes OK + if s.Finished < 10 { + s.Finished = time.Now().UnixMilli() + } + s.Running = false + if s.Status == "" { + s.Status = "done" + } + s.Target = e.rootDir + e.status = s + e.broadcaster(s) + }() + + err := e.doExportWithHistory() + if err != nil { + e.logger.Error("ERROR", "e", err) + e.status.Status = "ERROR" + e.status.Last = err.Error() + e.broadcaster(e.status) + } +} + +func (e *gitExportJob) doExportWithHistory() error { + r, err := git.PlainInit(e.rootDir, false) + if err != nil { + return err + } + // default to "main" branch + h := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.ReferenceName("refs/heads/main")) + err = r.Storer.SetReference(h) + if err != nil { + return err + } + + w, err := r.Worktree() + if err != nil { + return err + } + helper := &commitHelper{ + repo: r, + work: w, + ctx: context.Background(), + workDir: e.rootDir, + orgDir: e.rootDir, + } + + cmd := &models.SearchOrgsQuery{} + err = e.sql.SearchOrgs(helper.ctx, cmd) + if err != nil { + return err + } + + // Export each org + for _, org := range cmd.Result { + if len(cmd.Result) > 1 { + helper.orgDir = path.Join(e.rootDir, fmt.Sprintf("org_%d", org.Id)) + } + err = helper.initOrg(e.sql, org.Id) + if err != nil { + return err + } + + err = e.doOrgExportWithHistory(helper) + if err != nil { + return err + } + } + + // cleanup the folder + e.status.Target = "pruning..." + e.broadcaster(e.status) + err = r.Prune(git.PruneOptions{}) + + // TODO + // git gc --prune=now --aggressive + + return err +} + +func (e *gitExportJob) doOrgExportWithHistory(helper *commitHelper) error { + lookup, err := exportDataSources(helper, e) + if err != nil { + return err + } + + if true { + err = exportDashboards(helper, e, lookup) + if err != nil { + return err + } + } + + // Run all the simple exporters + exporters := []simpleExporter{ + dumpAuthTables, + exportSystemPreferences, + exportSystemStars, + exportSystemPlaylists, + exportAnnotations, + } + + // This needs a real admin user to use the interfaces (and decrypt) + if false { + exporters = append(exporters, exportSnapshots) + } + + for _, fn := range exporters { + err = fn(helper, e) + if err != nil { + return err + } + } + return err +} + +/** + +git remote add origin git@github.com:ryantxu/test-dash-repo.git +git branch -M main +git push -u origin main + +**/ diff --git a/pkg/services/export/service.go b/pkg/services/export/service.go index ee4f1b040ea..6551775a065 100644 --- a/pkg/services/export/service.go +++ b/pkg/services/export/service.go @@ -2,15 +2,21 @@ package export import ( "encoding/json" + "fmt" "net/http" + "os" + "path/filepath" "sync" + "time" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboardsnapshots" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/live" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" ) type ExportService interface { @@ -22,25 +28,31 @@ type ExportService interface { } type StandardExport struct { - logger log.Logger - sql *sqlstore.SQLStore - glive *live.GrafanaLive - mutex sync.Mutex + logger log.Logger + glive *live.GrafanaLive + mutex sync.Mutex + dataDir string + + // Services + sql *sqlstore.SQLStore + dashboardsnapshotsService dashboardsnapshots.Service // updated with mutex exportJob Job } -func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles, gl *live.GrafanaLive) ExportService { +func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles, gl *live.GrafanaLive, cfg *setting.Cfg, dashboardsnapshotsService dashboardsnapshots.Service) ExportService { if !features.IsEnabled(featuremgmt.FlagExport) { return &StubExport{} } return &StandardExport{ - sql: sql, - glive: gl, - logger: log.New("export_service"), - exportJob: &stoppedJob{}, + sql: sql, + glive: gl, + logger: log.New("export_service"), + dashboardsnapshotsService: dashboardsnapshotsService, + exportJob: &stoppedJob{}, + dataDir: cfg.DataPath, } } @@ -67,9 +79,23 @@ func (ex *StandardExport) HandleRequestExport(c *models.ReqContext) response.Res return response.Error(http.StatusLocked, "export already running", nil) } - job, err := startDummyExportJob(cfg, func(s ExportStatus) { + var job Job + broadcast := func(s ExportStatus) { ex.broadcastStatus(c.OrgId, s) - }) + } + switch cfg.Format { + case "dummy": + job, err = startDummyExportJob(cfg, broadcast) + case "git": + dir := filepath.Join(ex.dataDir, "export_git", fmt.Sprintf("git_%d", time.Now().Unix())) + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return response.Error(http.StatusBadRequest, "Error creating export folder", nil) + } + job, err = startGitExportJob(cfg, ex.sql, ex.dashboardsnapshotsService, dir, c.OrgId, broadcast) + default: + return response.Error(http.StatusBadRequest, "Unsupported job format", nil) + } + if err != nil { ex.logger.Error("failed to start export job", "err", err) return response.Error(http.StatusBadRequest, "failed to start export job", err) diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 75e64a2ebca..81c39c2bace 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -227,6 +227,12 @@ var ( State: FeatureStateAlpha, FrontendOnly: true, }, + { + Name: "scenes", + Description: "Experimental framework to build interactive dashboards", + State: FeatureStateAlpha, + FrontendOnly: true, + }, { Name: "useLegacyHeatmapPanel", Description: "Continue to use the angular/flot based heatmap panel", diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index d9ff3c122ae..d95e7241994 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -167,6 +167,10 @@ const ( // Allow elements nesting FlagCanvasPanelNesting = "canvasPanelNesting" + // FlagScenes + // Experimental framework to build interactive dashboards + FlagScenes = "scenes" + // FlagUseLegacyHeatmapPanel // Continue to use the angular/flot based heatmap panel FlagUseLegacyHeatmapPanel = "useLegacyHeatmapPanel" diff --git a/pkg/services/kmsproviders/osskmsproviders/osskmsproviders.go b/pkg/services/kmsproviders/osskmsproviders/osskmsproviders.go index 4292ed16a58..511e7dc7091 100644 --- a/pkg/services/kmsproviders/osskmsproviders/osskmsproviders.go +++ b/pkg/services/kmsproviders/osskmsproviders/osskmsproviders.go @@ -24,10 +24,6 @@ func ProvideService(enc encryption.Internal, settings setting.Provider, features } func (s Service) Provide() (map[secrets.ProviderID]secrets.Provider, error) { - if s.features.IsEnabled(featuremgmt.FlagDisableEnvelopeEncryption) { - return nil, nil - } - return map[secrets.ProviderID]secrets.Provider{ kmsproviders.Default: grafana.New(s.settings, s.enc), }, nil diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go index d1a6a1f1d59..2569d256b9f 100644 --- a/pkg/services/ngalert/api/api_provisioning.go +++ b/pkg/services/ngalert/api/api_provisioning.go @@ -54,6 +54,7 @@ type AlertRuleService interface { CreateAlertRule(ctx context.Context, rule alerting_models.AlertRule, provenance alerting_models.Provenance) (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) (definitions.AlertRuleGroup, error) UpdateRuleGroup(ctx context.Context, orgID int64, folderUID, rulegroup string, interval int64) error } @@ -284,7 +285,18 @@ func (srv *ProvisioningSrv) RouteDeleteAlertRule(c *models.ReqContext, UID strin return response.JSON(http.StatusNoContent, "") } -func (srv *ProvisioningSrv) RoutePutAlertRuleGroup(c *models.ReqContext, ag definitions.AlertRuleGroup, folderUID string, group string) response.Response { +func (srv *ProvisioningSrv) RouteGetAlertRuleGroup(c *models.ReqContext, folder string, group string) response.Response { + g, err := srv.alertRules.GetRuleGroup(c.Req.Context(), c.OrgId, folder, group) + if err != nil { + if errors.Is(err, store.ErrAlertRuleGroupNotFound) { + return ErrResp(http.StatusNotFound, err, "") + } + return ErrResp(http.StatusInternalServerError, err, "") + } + return response.JSON(http.StatusOK, g) +} + +func (srv *ProvisioningSrv) RoutePutAlertRuleGroup(c *models.ReqContext, ag definitions.AlertRuleGroupMetadata, folderUID string, group string) response.Response { err := srv.alertRules.UpdateRuleGroup(c.Req.Context(), c.OrgId, folderUID, group, ag.Interval) if err != nil { if errors.Is(err, store.ErrOptimisticLock) { diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index 118dd0edb27..88574902f5f 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -239,6 +239,28 @@ func TestProvisioningApi(t *testing.T) { require.Equal(t, 404, response.Status()) }) }) + + t.Run("alert rule groups", func(t *testing.T) { + t.Run("are present, GET returns 200", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + response := sut.RouteGetAlertRuleGroup(&rc, "folder-uid", "my-cool-group") + + require.Equal(t, 200, response.Status()) + }) + + t.Run("are missing, GET returns 404", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + insertRule(t, sut, createTestAlertRule("rule", 1)) + + response := sut.RouteGetAlertRuleGroup(&rc, "folder-uid", "does not exist") + + require.Equal(t, 404, response.Status()) + }) + }) } func createProvisioningSrvSut(t *testing.T) ProvisioningSrv { @@ -382,6 +404,7 @@ func createTestAlertRule(title string, orgID int64) definitions.AlertRule { }, }, RuleGroup: "my-cool-group", + FolderUID: "folder-uid", For: time.Second * 60, NoDataState: models.OK, ExecErrState: models.OkErrState, diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index a2ebca1fb1f..49f2ad16cd2 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -185,7 +185,8 @@ func (api *API) authorize(method, path string) web.Handler { http.MethodGet + "/api/v1/provisioning/templates/{name}", http.MethodGet + "/api/v1/provisioning/mute-timings", http.MethodGet + "/api/v1/provisioning/mute-timings/{name}", - http.MethodGet + "/api/v1/provisioning/alert-rules/{UID}": + http.MethodGet + "/api/v1/provisioning/alert-rules/{UID}", + http.MethodGet + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": fallback = middleware.ReqOrgAdmin eval = ac.EvalPermission(ac.ActionAlertingProvisioningRead) // organization scope diff --git a/pkg/services/ngalert/api/forked_provisioning.go b/pkg/services/ngalert/api/forked_provisioning.go index 6446ef47bb5..3339ab10d1f 100644 --- a/pkg/services/ngalert/api/forked_provisioning.go +++ b/pkg/services/ngalert/api/forked_provisioning.go @@ -95,6 +95,10 @@ func (f *ForkedProvisioningApi) forkRouteDeleteAlertRule(ctx *models.ReqContext, return f.svc.RouteDeleteAlertRule(ctx, UID) } -func (f *ForkedProvisioningApi) forkRoutePutAlertRuleGroup(ctx *models.ReqContext, ag apimodels.AlertRuleGroup, folder, group string) response.Response { +func (f *ForkedProvisioningApi) forkRouteGetAlertRuleGroup(ctx *models.ReqContext, folder, group string) response.Response { + return f.svc.RouteGetAlertRuleGroup(ctx, folder, group) +} + +func (f *ForkedProvisioningApi) forkRoutePutAlertRuleGroup(ctx *models.ReqContext, ag apimodels.AlertRuleGroupMetadata, folder, group string) response.Response { return f.svc.RoutePutAlertRuleGroup(ctx, ag, folder, group) } diff --git a/pkg/services/ngalert/api/generated_base_api_provisioning.go b/pkg/services/ngalert/api/generated_base_api_provisioning.go index b449a168658..8fb66c89cc1 100644 --- a/pkg/services/ngalert/api/generated_base_api_provisioning.go +++ b/pkg/services/ngalert/api/generated_base_api_provisioning.go @@ -24,6 +24,7 @@ type ProvisioningApiForkingService interface { RouteDeleteMuteTiming(*models.ReqContext) response.Response RouteDeleteTemplate(*models.ReqContext) response.Response RouteGetAlertRule(*models.ReqContext) response.Response + RouteGetAlertRuleGroup(*models.ReqContext) response.Response RouteGetContactpoints(*models.ReqContext) response.Response RouteGetMuteTiming(*models.ReqContext) response.Response RouteGetMuteTimings(*models.ReqContext) response.Response @@ -61,6 +62,11 @@ func (f *ForkedProvisioningApi) RouteGetAlertRule(ctx *models.ReqContext) respon uIDParam := web.Params(ctx.Req)[":UID"] return f.forkRouteGetAlertRule(ctx, uIDParam) } +func (f *ForkedProvisioningApi) RouteGetAlertRuleGroup(ctx *models.ReqContext) response.Response { + folderUIDParam := web.Params(ctx.Req)[":FolderUID"] + groupParam := web.Params(ctx.Req)[":Group"] + return f.forkRouteGetAlertRuleGroup(ctx, folderUIDParam, groupParam) +} func (f *ForkedProvisioningApi) RouteGetContactpoints(ctx *models.ReqContext) response.Response { return f.forkRouteGetContactpoints(ctx) } @@ -113,7 +119,7 @@ func (f *ForkedProvisioningApi) RoutePutAlertRule(ctx *models.ReqContext) respon func (f *ForkedProvisioningApi) RoutePutAlertRuleGroup(ctx *models.ReqContext) response.Response { folderUIDParam := web.Params(ctx.Req)[":FolderUID"] groupParam := web.Params(ctx.Req)[":Group"] - conf := apimodels.AlertRuleGroup{} + conf := apimodels.AlertRuleGroupMetadata{} if err := web.Bind(ctx.Req, &conf); err != nil { return response.Error(http.StatusBadRequest, "bad request data", err) } @@ -203,6 +209,16 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingServi m, ), ) + group.Get( + toMacaronPath("/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}"), + api.authorize(http.MethodGet, "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}"), + metrics.Instrument( + http.MethodGet, + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}", + srv.RouteGetAlertRuleGroup, + m, + ), + ) group.Get( toMacaronPath("/api/v1/provisioning/contact-points"), api.authorize(http.MethodGet, "/api/v1/provisioning/contact-points"), diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index e976a0da3fd..60645a449f3 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -308,7 +308,7 @@ ], "type": "object" }, - "AlertRuleGroup": { + "AlertRuleGroupMetadata": { "properties": { "interval": { "format": "int64", @@ -470,30 +470,6 @@ }, "type": "array" }, - "DashboardAclUpdateItem": { - "properties": { - "permission": { - "$ref": "#/definitions/PermissionType" - }, - "role": { - "enum": [ - "Viewer", - "Editor", - "Admin" - ], - "type": "string" - }, - "teamId": { - "format": "int64", - "type": "integer" - }, - "userId": { - "format": "int64", - "type": "integer" - } - }, - "type": "object" - }, "DateTime": { "description": "DateTime is a time but it serializes to ISO8601 format with millis\nIt knows how to read 3 different variations of a RFC3339 date time.\nMost APIs we encounter want either millisecond or second precision times.\nThis just tries to make it worry-free.", "format": "date-time", @@ -1336,48 +1312,6 @@ }, "type": "array" }, - "MetricRequest": { - "properties": { - "debug": { - "type": "boolean" - }, - "from": { - "description": "From Start time in epoch timestamps in milliseconds or relative using Grafana time units.", - "example": "now-1h", - "type": "string" - }, - "queries": { - "description": "queries.refId – Specifies an identifier of the query. Is optional and default to “A”.\nqueries.datasourceId – Specifies the data source to be queried. Each query in the request must have an unique datasourceId.\nqueries.maxDataPoints - Species maximum amount of data points that dashboard panel can render. Is optional and default to 100.\nqueries.intervalMs - Specifies the time interval in milliseconds of time series. Is optional and defaults to 1000.", - "example": [ - { - "datasource": { - "uid": "PD8C576611E62080A" - }, - "format": "table", - "intervalMs": 86400000, - "maxDataPoints": 1092, - "rawSql": "SELECT 1 as valueOne, 2 as valueTwo", - "refId": "A" - } - ], - "items": { - "$ref": "#/definitions/Json" - }, - "type": "array" - }, - "to": { - "description": "To End time in epoch timestamps in milliseconds or relative using Grafana time units.", - "example": "now", - "type": "string" - } - }, - "required": [ - "from", - "to", - "queries" - ], - "type": "object" - }, "MonthRange": { "properties": { "Begin": { @@ -1425,34 +1359,6 @@ }, "type": "object" }, - "NavLink": { - "properties": { - "id": { - "type": "string" - }, - "target": { - "type": "string" - }, - "text": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "type": "object" - }, - "NavbarPreference": { - "properties": { - "savedItems": { - "items": { - "$ref": "#/definitions/NavLink" - }, - "type": "array" - } - }, - "type": "object" - }, "NotFound": { "type": "object" }, @@ -1668,53 +1574,9 @@ }, "type": "object" }, - "PatchPrefsCmd": { - "properties": { - "homeDashboardId": { - "default": 0, - "description": "The numerical :id of a favorited dashboard", - "format": "int64", - "type": "integer" - }, - "homeDashboardUID": { - "type": "string" - }, - "locale": { - "type": "string" - }, - "navbar": { - "$ref": "#/definitions/NavbarPreference" - }, - "queryHistory": { - "$ref": "#/definitions/QueryHistoryPreference" - }, - "theme": { - "enum": [ - "light", - "dark" - ], - "type": "string" - }, - "timezone": { - "enum": [ - "utc", - "browser" - ], - "type": "string" - }, - "weekStart": { - "type": "string" - } - }, - "type": "object" - }, "PermissionDenied": { "type": "object" }, - "PermissionType": { - "format": "int64", - "type": "integer" - }, "Point": { "properties": { "T": { @@ -2036,14 +1898,6 @@ }, "type": "object" }, - "QueryHistoryPreference": { - "properties": { - "homeTab": { - "type": "string" - } - }, - "type": "object" - }, "Receiver": { "properties": { "email_configs": { @@ -2738,6 +2592,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 RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.", "properties": { "ForceQuery": { "type": "boolean" @@ -2770,58 +2625,7 @@ "$ref": "#/definitions/Userinfo" } }, - "title": "URL is a custom URL type that allows validation at configuration load time.", - "type": "object" - }, - "UpdateDashboardAclCommand": { - "properties": { - "items": { - "items": { - "$ref": "#/definitions/DashboardAclUpdateItem" - }, - "type": "array" - } - }, - "type": "object" - }, - "UpdatePrefsCmd": { - "properties": { - "homeDashboardId": { - "default": 0, - "description": "The numerical :id of a favorited dashboard", - "format": "int64", - "type": "integer" - }, - "homeDashboardUID": { - "type": "string" - }, - "locale": { - "type": "string" - }, - "navbar": { - "$ref": "#/definitions/NavbarPreference" - }, - "queryHistory": { - "$ref": "#/definitions/QueryHistoryPreference" - }, - "theme": { - "enum": [ - "light", - "dark" - ], - "type": "string" - }, - "timezone": { - "enum": [ - "utc", - "browser" - ], - "type": "string" - }, - "weekStart": { - "type": "string" - } - }, + "title": "A URL represents a parsed URL (technically, a URI reference).", "type": "object" }, "Userinfo": { @@ -2991,7 +2795,6 @@ "type": "object" }, "alertGroup": { - "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -3015,7 +2818,6 @@ "type": "object" }, "alertGroups": { - "description": "AlertGroups alert groups", "items": { "$ref": "#/definitions/alertGroup" }, @@ -3123,6 +2925,7 @@ "$ref": "#/definitions/Duration" }, "gettableAlert": { + "description": "GettableAlert gettable alert", "properties": { "annotations": { "$ref": "#/definitions/labelSet" @@ -3184,7 +2987,6 @@ "type": "array" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -3344,7 +3146,6 @@ "type": "array" }, "postableSilence": { - "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -3382,6 +3183,7 @@ "type": "object" }, "receiver": { + "description": "Receiver receiver", "properties": { "name": { "description": "name", @@ -3730,6 +3532,35 @@ } }, "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": { + "get": { + "operationId": "RouteGetAlertRuleGroup", + "parameters": [ + { + "in": "path", + "name": "FolderUID", + "required": true, + "type": "string" + }, + { + "in": "path", + "name": "Group", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "$ref": "#/responses/AlertRuleGroup" + }, + "404": { + "description": " Not found." + } + }, + "summary": "Get a rule group.", + "tags": [ + "provisioning" + ] + }, "put": { "consumes": [ "application/json" @@ -3752,15 +3583,15 @@ "in": "body", "name": "Body", "schema": { - "$ref": "#/definitions/AlertRuleGroup" + "$ref": "#/definitions/AlertRuleGroupMetadata" } } ], "responses": { "200": { - "description": "AlertRuleGroup", + "description": "AlertRuleGroupMetadata", "schema": { - "$ref": "#/definitions/AlertRuleGroup" + "$ref": "#/definitions/AlertRuleGroupMetadata" } }, "400": { diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go index 5b6365ea412..20338b4fc5a 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go @@ -101,6 +101,34 @@ func (r *Route) Validate() error { 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/provisioning_alert_rules.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go index 44a7df7491e..d54f27090bd 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go @@ -135,6 +135,14 @@ func NewAlertRule(rule models.AlertRule, provenance models.Provenance) AlertRule } } +// swagger:route GET /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group} provisioning stable RouteGetAlertRuleGroup +// +// Get a rule group. +// +// Responses: +// 200: AlertRuleGroup +// 404: description: Not found. + // swagger:route PUT /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group} provisioning stable RoutePutAlertRuleGroup // // Update the interval of a rule group. @@ -143,16 +151,16 @@ func NewAlertRule(rule models.AlertRule, provenance models.Provenance) AlertRule // - application/json // // Responses: -// 200: AlertRuleGroup +// 200: AlertRuleGroupMetadata // 400: ValidationError -// swagger:parameters RoutePutAlertRuleGroup +// swagger:parameters RouteGetAlertRuleGroup RoutePutAlertRuleGroup type FolderUIDPathParam struct { // in:path FolderUID string `json:"FolderUID"` } -// swagger:parameters RoutePutAlertRuleGroup +// swagger:parameters RouteGetAlertRuleGroup RoutePutAlertRuleGroup type RuleGroupPathParam struct { // in:path Group string `json:"Group"` @@ -161,9 +169,16 @@ type RuleGroupPathParam struct { // swagger:parameters RoutePutAlertRuleGroup type AlertRuleGroupPayload struct { // in:body - Body AlertRuleGroup + Body AlertRuleGroupMetadata +} + +type AlertRuleGroupMetadata struct { + Interval int64 `json:"interval"` } type AlertRuleGroup struct { - Interval int64 `json:"interval"` + Title string `json:"title"` + FolderUID string `json:"folderUid"` + Interval int64 `json:"interval"` + Rules []models.AlertRule `json:"rules"` } diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 5f1dc277dda..05c7dbeb931 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -308,7 +308,7 @@ ], "type": "object" }, - "AlertRuleGroup": { + "AlertRuleGroupMetadata": { "properties": { "interval": { "format": "int64", @@ -2592,6 +2592,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 RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.", "properties": { "ForceQuery": { "type": "boolean" @@ -2624,7 +2625,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" }, "Userinfo": { @@ -2794,6 +2795,7 @@ "type": "object" }, "alertGroup": { + "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -2988,6 +2990,7 @@ "type": "array" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -3146,6 +3149,7 @@ "type": "array" }, "postableSilence": { + "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -5157,6 +5161,35 @@ } }, "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": { + "get": { + "operationId": "RouteGetAlertRuleGroup", + "parameters": [ + { + "in": "path", + "name": "FolderUID", + "required": true, + "type": "string" + }, + { + "in": "path", + "name": "Group", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "$ref": "#/responses/AlertRuleGroup" + }, + "404": { + "description": " Not found." + } + }, + "summary": "Get a rule group.", + "tags": [ + "provisioning" + ] + }, "put": { "consumes": [ "application/json" @@ -5179,15 +5212,15 @@ "in": "body", "name": "Body", "schema": { - "$ref": "#/definitions/AlertRuleGroup" + "$ref": "#/definitions/AlertRuleGroupMetadata" } } ], "responses": { "200": { - "description": "AlertRuleGroup", + "description": "AlertRuleGroupMetadata", "schema": { - "$ref": "#/definitions/AlertRuleGroup" + "$ref": "#/definitions/AlertRuleGroupMetadata" } }, "400": { diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 61d42c493db..6878508f9b7 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1893,6 +1893,36 @@ } }, "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}": { + "get": { + "tags": [ + "provisioning", + "stable" + ], + "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": { + "$ref": "#/responses/AlertRuleGroup" + }, + "404": { + "description": " Not found." + } + } + }, "put": { "consumes": [ "application/json" @@ -1920,15 +1950,15 @@ "name": "Body", "in": "body", "schema": { - "$ref": "#/definitions/AlertRuleGroup" + "$ref": "#/definitions/AlertRuleGroupMetadata" } } ], "responses": { "200": { - "description": "AlertRuleGroup", + "description": "AlertRuleGroupMetadata", "schema": { - "$ref": "#/definitions/AlertRuleGroup" + "$ref": "#/definitions/AlertRuleGroupMetadata" } }, "400": { @@ -2629,7 +2659,7 @@ } } }, - "AlertRuleGroup": { + "AlertRuleGroupMetadata": { "type": "object", "properties": { "interval": { @@ -4917,8 +4947,9 @@ } }, "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 RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.", "type": "object", - "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).", "properties": { "ForceQuery": { "type": "boolean" @@ -5119,6 +5150,7 @@ } }, "alertGroup": { + "description": "AlertGroup alert group", "type": "object", "required": [ "alerts", @@ -5143,7 +5175,6 @@ "$ref": "#/definitions/alertGroup" }, "alertGroups": { - "description": "AlertGroups alert groups", "type": "array", "items": { "$ref": "#/definitions/alertGroup" @@ -5252,7 +5283,6 @@ "$ref": "#/definitions/Duration" }, "gettableAlert": { - "description": "GettableAlert gettable alert", "type": "object", "required": [ "labels", @@ -5309,7 +5339,6 @@ "$ref": "#/definitions/gettableAlert" }, "gettableAlerts": { - "description": "GettableAlerts gettable alerts", "type": "array", "items": { "$ref": "#/definitions/gettableAlert" @@ -5515,6 +5544,7 @@ "$ref": "#/definitions/postableSilence" }, "receiver": { + "description": "Receiver receiver", "type": "object", "required": [ "name" diff --git a/pkg/services/ngalert/notifier/available_channels.go b/pkg/services/ngalert/notifier/available_channels.go index 948a70102b1..48c82c7bc7d 100644 --- a/pkg/services/ngalert/notifier/available_channels.go +++ b/pkg/services/ngalert/notifier/available_channels.go @@ -696,6 +696,14 @@ func GetAvailableNotifiers() []*alerting.NotifierPlugin { Placeholder: `{{ template "default.message" . }}`, PropertyName: "message", }, + { // New in 9.1. + Label: "Title", + Element: alerting.ElementTypeInput, + InputType: alerting.InputTypeText, + Description: "Templated title of the message", + PropertyName: "title", + Placeholder: `{{ template "default.title" . }}`, + }, }, }, { diff --git a/pkg/services/ngalert/notifier/channels/wecom.go b/pkg/services/ngalert/notifier/channels/wecom.go index 12fb42ced1e..4d25c1b8eff 100644 --- a/pkg/services/ngalert/notifier/channels/wecom.go +++ b/pkg/services/ngalert/notifier/channels/wecom.go @@ -18,6 +18,7 @@ type WeComConfig struct { *NotificationChannelConfig URL string Message string + Title string } func WeComFactory(fc FactoryConfig) (NotificationChannel, error) { @@ -40,6 +41,7 @@ func NewWeComConfig(config *NotificationChannelConfig, decryptFunc GetDecryptedV NotificationChannelConfig: config, URL: url, Message: config.Settings.Get("message").MustString(`{{ template "default.message" .}}`), + Title: config.Settings.Get("title").MustString(DefaultMessageTitleEmbed), }, nil } @@ -55,6 +57,7 @@ func NewWeComNotifier(config *WeComConfig, ns notifications.WebhookSender, t *te }), URL: config.URL, Message: config.Message, + Title: config.Title, log: log.New("alerting.notifier.wecom"), ns: ns, tmpl: t, @@ -66,6 +69,7 @@ type WeComNotifier struct { *Base URL string Message string + Title string tmpl *template.Template log log.Logger ns notifications.WebhookSender @@ -82,7 +86,7 @@ func (w *WeComNotifier) Notify(ctx context.Context, as ...*types.Alert) (bool, e "msgtype": "markdown", } content := fmt.Sprintf("# %s\n%s\n", - tmpl(DefaultMessageTitleEmbed), + tmpl(w.Title), tmpl(w.Message), ) diff --git a/pkg/services/ngalert/notifier/channels/wecom_test.go b/pkg/services/ngalert/notifier/channels/wecom_test.go index 4c7bfd2b49f..d5343f360b6 100644 --- a/pkg/services/ngalert/notifier/channels/wecom_test.go +++ b/pkg/services/ngalert/notifier/channels/wecom_test.go @@ -76,6 +76,33 @@ func TestWeComNotifier(t *testing.T) { "msgtype": "markdown", }, expMsgError: nil, + }, { + name: "Custom title and message with multiple alerts", + settings: `{ + "url": "http://localhost", + "message": "{{ len .Alerts.Firing }} alerts are firing, {{ len .Alerts.Resolved }} are resolved", + "title": "This notification is {{ .Status }}!" + }`, + alerts: []*types.Alert{ + { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val1"}, + Annotations: model.LabelSet{"ann1": "annv1"}, + }, + }, { + Alert: model.Alert{ + Labels: model.LabelSet{"alertname": "alert1", "lbl1": "val2"}, + Annotations: model.LabelSet{"ann1": "annv2"}, + }, + }, + }, + expMsg: map[string]interface{}{ + "markdown": map[string]interface{}{ + "content": "# This notification is firing!\n2 alerts are firing, 0 are resolved\n", + }, + "msgtype": "markdown", + }, + expMsgError: nil, }, { name: "Error in initing", settings: `{}`, diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go index 04e4e9a8987..5d618317eda 100644 --- a/pkg/services/ngalert/provisioning/alert_rules.go +++ b/pkg/services/ngalert/provisioning/alert_rules.go @@ -7,6 +7,7 @@ import ( "time" "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/grafana/grafana/pkg/util" @@ -89,6 +90,32 @@ func (service *AlertRuleService) CreateAlertRule(ctx context.Context, rule model return rule, nil } +func (service *AlertRuleService) GetRuleGroup(ctx context.Context, orgID int64, folder, group string) (definitions.AlertRuleGroup, error) { + q := models.ListAlertRulesQuery{ + OrgID: orgID, + NamespaceUIDs: []string{folder}, + RuleGroup: group, + } + if err := service.ruleStore.ListAlertRules(ctx, &q); err != nil { + return definitions.AlertRuleGroup{}, err + } + if len(q.Result) == 0 { + return definitions.AlertRuleGroup{}, store.ErrAlertRuleGroupNotFound + } + res := definitions.AlertRuleGroup{ + Title: q.Result[0].RuleGroup, + FolderUID: q.Result[0].NamespaceUID, + Interval: q.Result[0].IntervalSeconds, + Rules: []models.AlertRule{}, + } + for _, r := range q.Result { + if r != nil { + res.Rules = append(res.Rules, *r) + } + } + return res, nil +} + // UpdateRuleGroup will update the interval for all rules in the group. func (service *AlertRuleService) UpdateRuleGroup(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string, interval int64) error { if err := models.ValidateRuleGroupInterval(interval, service.baseIntervalSeconds); err != nil { diff --git a/pkg/services/ngalert/provisioning/notification_policies.go b/pkg/services/ngalert/provisioning/notification_policies.go index f03611fc86d..8597360fa03 100644 --- a/pkg/services/ngalert/provisioning/notification_policies.go +++ b/pkg/services/ngalert/provisioning/notification_policies.go @@ -16,7 +16,8 @@ type NotificationPolicyService struct { log log.Logger } -func NewNotificationPolicyService(am AMConfigStore, prov ProvisioningStore, xact TransactionManager, log log.Logger) *NotificationPolicyService { +func NewNotificationPolicyService(am AMConfigStore, prov ProvisioningStore, + xact TransactionManager, log log.Logger) *NotificationPolicyService { return &NotificationPolicyService{ amStore: am, provenanceStore: prov, @@ -63,11 +64,27 @@ func (nps *NotificationPolicyService) UpdatePolicyTree(ctx context.Context, orgI if err != nil { return fmt.Errorf("%w: %s", ErrValidation, err.Error()) } + revision, err := getLastConfiguration(ctx, orgID, nps.amStore) if err != nil { return err } + receivers, err := nps.receiversToMap(revision.cfg.AlertmanagerConfig.Receivers) + err = tree.ValidateReceivers(receivers) + if err != nil { + return fmt.Errorf("%w: %s", ErrValidation, err.Error()) + } + + muteTimes := map[string]struct{}{} + for _, mt := range revision.cfg.AlertmanagerConfig.MuteTimeIntervals { + muteTimes[mt.Name] = struct{}{} + } + err = tree.ValidateMuteTimes(muteTimes) + if err != nil { + return fmt.Errorf("%w: %s", ErrValidation, err.Error()) + } + revision.cfg.AlertmanagerConfig.Config.Route = &tree serialized, err := serializeAlertmanagerConfig(*revision.cfg) @@ -98,3 +115,11 @@ func (nps *NotificationPolicyService) UpdatePolicyTree(ctx context.Context, orgI return nil } + +func (nps *NotificationPolicyService) receiversToMap(records []*definitions.PostableApiReceiver) (map[string]struct{}, error) { + receivers := map[string]struct{}{} + for _, receiver := range records { + receivers[receiver.Name] = struct{}{} + } + return receivers, nil +} diff --git a/pkg/services/ngalert/provisioning/notification_policies_test.go b/pkg/services/ngalert/provisioning/notification_policies_test.go index df98eb4f7c6..cff09e8899a 100644 --- a/pkg/services/ngalert/provisioning/notification_policies_test.go +++ b/pkg/services/ngalert/provisioning/notification_policies_test.go @@ -7,7 +7,10 @@ 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/prometheus/alertmanager/config" + "github.com/prometheus/alertmanager/timeinterval" "github.com/prometheus/common/model" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -21,8 +24,85 @@ func TestNotificationPolicyService(t *testing.T) { require.Equal(t, "grafana-default-email", tree.Receiver) }) + t.Run("error if referenced mute time interval is not existing", func(t *testing.T) { + sut := createNotificationPolicyServiceSut() + sut.amStore = &MockAMConfigStore{} + sut.amStore.(*MockAMConfigStore).On("GetLatestAlertmanagerConfiguration", mock.Anything, mock.Anything). + Return( + func(ctx context.Context, query *models.GetLatestAlertmanagerConfigurationQuery) error { + cfg, _ := deserializeAlertmanagerConfig([]byte(defaultConfig)) + mti := config.MuteTimeInterval{ + Name: "not-the-one-we-need", + TimeIntervals: []timeinterval.TimeInterval{}, + } + cfg.AlertmanagerConfig.MuteTimeIntervals = append(cfg.AlertmanagerConfig.MuteTimeIntervals, mti) + cfg.AlertmanagerConfig.Receivers = append(cfg.AlertmanagerConfig.Receivers, + &definitions.PostableApiReceiver{ + Receiver: config.Receiver{ + // default one from createTestRoutingTree() + Name: "a new receiver", + }, + }) + data, _ := serializeAlertmanagerConfig(*cfg) + query.Result = &models.AlertConfiguration{ + AlertmanagerConfiguration: string(data), + } + return nil + }) + sut.amStore.(*MockAMConfigStore).EXPECT(). + UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything). + Return(nil) + newRoute := createTestRoutingTree() + newRoute.Routes = append(newRoute.Routes, &definitions.Route{ + Receiver: "a new receiver", + MuteTimeIntervals: []string{"not-existing"}, + }) + + err := sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceNone) + require.Error(t, err) + }) + + t.Run("pass if referenced mute time interval is existing", func(t *testing.T) { + sut := createNotificationPolicyServiceSut() + sut.amStore = &MockAMConfigStore{} + sut.amStore.(*MockAMConfigStore).On("GetLatestAlertmanagerConfiguration", mock.Anything, mock.Anything). + Return( + func(ctx context.Context, query *models.GetLatestAlertmanagerConfigurationQuery) error { + cfg, _ := deserializeAlertmanagerConfig([]byte(defaultConfig)) + mti := config.MuteTimeInterval{ + Name: "existing", + TimeIntervals: []timeinterval.TimeInterval{}, + } + cfg.AlertmanagerConfig.MuteTimeIntervals = append(cfg.AlertmanagerConfig.MuteTimeIntervals, mti) + cfg.AlertmanagerConfig.Receivers = append(cfg.AlertmanagerConfig.Receivers, + &definitions.PostableApiReceiver{ + Receiver: config.Receiver{ + // default one from createTestRoutingTree() + Name: "a new receiver", + }, + }) + data, _ := serializeAlertmanagerConfig(*cfg) + query.Result = &models.AlertConfiguration{ + AlertmanagerConfiguration: string(data), + } + return nil + }) + sut.amStore.(*MockAMConfigStore).EXPECT(). + UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything). + Return(nil) + newRoute := createTestRoutingTree() + newRoute.Routes = append(newRoute.Routes, &definitions.Route{ + Receiver: "a new receiver", + MuteTimeIntervals: []string{"existing"}, + }) + + err := sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceNone) + require.NoError(t, err) + }) + t.Run("service stitches policy tree into org's AM config", func(t *testing.T) { sut := createNotificationPolicyServiceSut() + newRoute := createTestRoutingTree() err := sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceNone) @@ -33,6 +113,56 @@ func TestNotificationPolicyService(t *testing.T) { require.Equal(t, "a new receiver", updated.Receiver) }) + t.Run("not existing receiver reference will error", func(t *testing.T) { + sut := createNotificationPolicyServiceSut() + + newRoute := createTestRoutingTree() + newRoute.Routes = append(newRoute.Routes, &definitions.Route{ + Receiver: "not-existing", + }) + + err := sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceNone) + require.Error(t, err) + }) + + t.Run("existing receiver reference will pass", func(t *testing.T) { + sut := createNotificationPolicyServiceSut() + sut.amStore = &MockAMConfigStore{} + sut.amStore.(*MockAMConfigStore).On("GetLatestAlertmanagerConfiguration", mock.Anything, mock.Anything). + Return( + func(ctx context.Context, query *models.GetLatestAlertmanagerConfigurationQuery) error { + cfg, _ := deserializeAlertmanagerConfig([]byte(defaultConfig)) + cfg.AlertmanagerConfig.Receivers = append(cfg.AlertmanagerConfig.Receivers, + &definitions.PostableApiReceiver{ + Receiver: config.Receiver{ + // default one from createTestRoutingTree() + Name: "a new receiver", + }, + }) + cfg.AlertmanagerConfig.Receivers = append(cfg.AlertmanagerConfig.Receivers, + &definitions.PostableApiReceiver{ + Receiver: config.Receiver{ + Name: "existing", + }, + }) + data, _ := serializeAlertmanagerConfig(*cfg) + query.Result = &models.AlertConfiguration{ + AlertmanagerConfiguration: string(data), + } + return nil + }) + sut.amStore.(*MockAMConfigStore).EXPECT(). + UpdateAlertmanagerConfiguration(mock.Anything, mock.Anything). + Return(nil) + newRoute := createTestRoutingTree() + newRoute.Routes = append(newRoute.Routes, &definitions.Route{ + Receiver: "existing", + }) + + err := sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceNone) + require.NoError(t, err) + }) + t.Run("default provenance of records is none", func(t *testing.T) { sut := createNotificationPolicyServiceSut() diff --git a/pkg/services/publicdashboards/api/api.go b/pkg/services/publicdashboards/api/api.go new file mode 100644 index 00000000000..65d87fc6568 --- /dev/null +++ b/pkg/services/publicdashboards/api/api.go @@ -0,0 +1,234 @@ +package api + +import ( + "errors" + "net/http" + "strconv" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "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/middleware" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins/backendplugin" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "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/publicdashboards" + . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/services/query" + "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/web" +) + +type Api struct { + PublicDashboardService publicdashboards.Service + RouteRegister routing.RouteRegister + AccessControl accesscontrol.AccessControl + QueryDataService *query.Service + Features *featuremgmt.FeatureManager +} + +func ProvideApi( + pd publicdashboards.Service, + rr routing.RouteRegister, + ac accesscontrol.AccessControl, + qds *query.Service, + features *featuremgmt.FeatureManager, +) *Api { + api := &Api{ + PublicDashboardService: pd, + RouteRegister: rr, + AccessControl: ac, + QueryDataService: qds, + Features: features, + } + + // attach api if PublicDashboards feature flag is enabled + if features.IsEnabled(featuremgmt.FlagPublicDashboards) { + api.RegisterAPIEndpoints() + } + + return api +} + +func (api *Api) RegisterAPIEndpoints() { + auth := accesscontrol.Middleware(api.AccessControl) + reqSignedIn := middleware.ReqSignedIn + + // Anonymous access to public dashboard route is configured in pkg/api/api.go + // because it is deeply dependent on the HTTPServer.Index() method and would result in a + // circular dependency + + api.RouteRegister.Get("/api/public/dashboards/:accessToken", routing.Wrap(api.GetPublicDashboard)) + api.RouteRegister.Post("/api/public/dashboards/:accessToken/panels/:panelId/query", routing.Wrap(api.QueryPublicDashboard)) + + // Create/Update Public Dashboard + api.RouteRegister.Get("/api/dashboards/uid/:uid/public-config", auth(reqSignedIn, accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(api.GetPublicDashboardConfig)) + api.RouteRegister.Post("/api/dashboards/uid/:uid/public-config", auth(reqSignedIn, accesscontrol.EvalPermission(dashboards.ActionDashboardsWrite)), routing.Wrap(api.SavePublicDashboardConfig)) +} + +// gets public dashboard +func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response { + accessToken := web.Params(c.Req)[":accessToken"] + + dash, err := api.PublicDashboardService.GetPublicDashboard(c.Req.Context(), accessToken) + if err != nil { + return handleDashboardErr(http.StatusInternalServerError, "Failed to get public dashboard", err) + } + + meta := dtos.DashboardMeta{ + Slug: dash.Slug, + Type: models.DashTypeDB, + CanStar: false, + CanSave: false, + CanEdit: false, + CanAdmin: false, + CanDelete: false, + Created: dash.Created, + Updated: dash.Updated, + Version: dash.Version, + IsFolder: false, + FolderId: dash.FolderId, + PublicDashboardAccessToken: accessToken, + } + + dto := dtos.DashboardFullWithMeta{Meta: meta, Dashboard: dash.Data} + + return response.JSON(http.StatusOK, dto) +} + +// gets public dashboard configuration for dashboard +func (api *Api) GetPublicDashboardConfig(c *models.ReqContext) response.Response { + pdc, err := api.PublicDashboardService.GetPublicDashboardConfig(c.Req.Context(), c.OrgId, web.Params(c.Req)[":uid"]) + if err != nil { + return handleDashboardErr(http.StatusInternalServerError, "Failed to get public dashboard config", err) + } + return response.JSON(http.StatusOK, pdc) +} + +// sets public dashboard configuration for dashboard +func (api *Api) SavePublicDashboardConfig(c *models.ReqContext) response.Response { + pubdash := &PublicDashboard{} + if err := web.Bind(c.Req, pubdash); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + + // Always set the org id to the current auth session orgId + pubdash.OrgId = c.OrgId + + dto := SavePublicDashboardConfigDTO{ + OrgId: c.OrgId, + DashboardUid: web.Params(c.Req)[":uid"], + UserId: c.UserId, + PublicDashboard: pubdash, + } + + pubdash, err := api.PublicDashboardService.SavePublicDashboardConfig(c.Req.Context(), &dto) + if err != nil { + return handleDashboardErr(http.StatusInternalServerError, "Failed to save public dashboard configuration", err) + } + + return response.JSON(http.StatusOK, pubdash) +} + +// QueryPublicDashboard returns all results for a given panel on a public dashboard +// POST /api/public/dashboard/:accessToken/panels/:panelId/query +func (api *Api) QueryPublicDashboard(c *models.ReqContext) response.Response { + panelId, err := strconv.ParseInt(web.Params(c.Req)[":panelId"], 10, 64) + if err != nil { + return response.Error(http.StatusBadRequest, "invalid panel ID", err) + } + + dashboard, err := api.PublicDashboardService.GetPublicDashboard(c.Req.Context(), web.Params(c.Req)[":accessToken"]) + if err != nil { + return response.Error(http.StatusInternalServerError, "could not fetch dashboard", err) + } + + publicDashboard, err := api.PublicDashboardService.GetPublicDashboardConfig(c.Req.Context(), dashboard.OrgId, dashboard.Uid) + if err != nil { + return response.Error(http.StatusInternalServerError, "could not fetch public dashboard", err) + } + + reqDTO, err := api.PublicDashboardService.BuildPublicDashboardMetricRequest( + c.Req.Context(), + dashboard, + publicDashboard, + panelId, + ) + if err != nil { + return handleDashboardErr(http.StatusInternalServerError, "Failed to get queries for public dashboard", err) + } + + anonymousUser, err := api.PublicDashboardService.BuildAnonymousUser(c.Req.Context(), dashboard) + + if err != nil { + return response.Error(http.StatusInternalServerError, "could not create anonymous user", err) + } + + resp, err := api.QueryDataService.QueryDataMultipleSources(c.Req.Context(), anonymousUser, c.SkipCache, reqDTO, true) + + if err != nil { + return handleQueryMetricsError(err) + } + return toJsonStreamingResponse(api.Features, resp) +} + +// util to help us unpack dashboard and publicdashboard errors or use default http code and message +// we should look to do some future refactoring of these errors as publicdashboard err is the same as a dashboarderr, just defined in a +// different package. +func handleDashboardErr(defaultCode int, defaultMsg string, err error) response.Response { + var publicDashboardErr PublicDashboardErr + + // handle public dashboard er + if ok := errors.As(err, &publicDashboardErr); ok { + return response.Error(publicDashboardErr.StatusCode, publicDashboardErr.Error(), publicDashboardErr) + } + + // handle dashboard errors as well + var dashboardErr dashboards.DashboardErr + if ok := errors.As(err, &dashboardErr); ok { + return response.Error(dashboardErr.StatusCode, dashboardErr.Error(), dashboardErr) + } + + return response.Error(defaultCode, defaultMsg, err) +} + +// Copied from pkg/api/metrics.go +func handleQueryMetricsError(err error) *response.NormalResponse { + if errors.Is(err, datasources.ErrDataSourceAccessDenied) { + return response.Error(http.StatusForbidden, "Access denied to data source", err) + } + if errors.Is(err, datasources.ErrDataSourceNotFound) { + return response.Error(http.StatusNotFound, "Data source not found", err) + } + var badQuery *query.ErrBadQuery + if errors.As(err, &badQuery) { + return response.Error(http.StatusBadRequest, util.Capitalize(badQuery.Message), err) + } + + if errors.Is(err, backendplugin.ErrPluginNotRegistered) { + return response.Error(http.StatusNotFound, "Plugin not found", err) + } + + return response.Error(http.StatusInternalServerError, "Query data error", err) +} + +// Copied from pkg/api/metrics.go +func toJsonStreamingResponse(features *featuremgmt.FeatureManager, qdr *backend.QueryDataResponse) response.Response { + statusWhenError := http.StatusBadRequest + if features.IsEnabled(featuremgmt.FlagDatasourceQueryMultiStatus) { + statusWhenError = http.StatusMultiStatus + } + + statusCode := http.StatusOK + for _, res := range qdr.Responses { + if res.Error != nil { + statusCode = statusWhenError + } + } + + return response.JSONStreaming(statusCode, qdr) +} diff --git a/pkg/services/publicdashboards/api/api_test.go b/pkg/services/publicdashboards/api/api_test.go new file mode 100644 index 00000000000..4eb0ea1ad4e --- /dev/null +++ b/pkg/services/publicdashboards/api/api_test.go @@ -0,0 +1,589 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/localcache" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + dashboardStore "github.com/grafana/grafana/pkg/services/dashboards/database" + "github.com/grafana/grafana/pkg/services/datasources" + fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" + "github.com/grafana/grafana/pkg/services/datasources/service" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/publicdashboards" + publicdashboardsStore "github.com/grafana/grafana/pkg/services/publicdashboards/database" + . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + publicdashboardsService "github.com/grafana/grafana/pkg/services/publicdashboards/service" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" +) + +func TestAPIGetPublicDashboard(t *testing.T) { + t.Run("It should 404 if featureflag is not enabled", func(t *testing.T) { + cfg := setting.NewCfg() + qs := buildQueryDataService(t, nil, nil, nil) + service := publicdashboards.NewFakePublicDashboardService(t) + service.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")). + Return(&models.Dashboard{}, nil).Maybe() + + testServer := setupTestServer(t, cfg, qs, featuremgmt.WithFeatures(), service, nil) + + response := callAPI(testServer, http.MethodGet, "/api/public/dashboards", nil, t) + assert.Equal(t, http.StatusNotFound, response.Code) + + response = callAPI(testServer, http.MethodGet, "/api/public/dashboards/asdf", nil, t) + assert.Equal(t, http.StatusNotFound, response.Code) + + // control set. make sure routes are mounted + testServer = setupTestServer(t, cfg, qs, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards), service, nil) + response = callAPI(testServer, http.MethodGet, "/api/public/dashboards/asdf", nil, t) + assert.NotEqual(t, http.StatusNotFound, response.Code) + }) + + DashboardUid := "dashboard-abcd1234" + token, err := uuid.NewRandom() + require.NoError(t, err) + accessToken := fmt.Sprintf("%x", token) + + testCases := []struct { + Name string + AccessToken string + ExpectedHttpResponse int + PublicDashboardResult *models.Dashboard + PublicDashboardErr error + }{ + { + Name: "It gets a public dashboard", + AccessToken: accessToken, + ExpectedHttpResponse: http.StatusOK, + PublicDashboardResult: &models.Dashboard{ + Data: simplejson.NewFromAny(map[string]interface{}{ + "Uid": DashboardUid, + }), + }, + PublicDashboardErr: nil, + }, + { + Name: "It should return 404 if no public dashboard", + AccessToken: accessToken, + ExpectedHttpResponse: http.StatusNotFound, + PublicDashboardResult: nil, + PublicDashboardErr: ErrPublicDashboardNotFound, + }, + } + + for _, test := range testCases { + t.Run(test.Name, func(t *testing.T) { + service := publicdashboards.NewFakePublicDashboardService(t) + service.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")). + Return(test.PublicDashboardResult, test.PublicDashboardErr).Maybe() + + testServer := setupTestServer( + t, + setting.NewCfg(), + buildQueryDataService(t, nil, nil, nil), + featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards), + service, + nil, + ) + + response := callAPI(testServer, http.MethodGet, + fmt.Sprintf("/api/public/dashboards/%s", test.AccessToken), + nil, + t, + ) + + assert.Equal(t, test.ExpectedHttpResponse, response.Code) + + if test.PublicDashboardErr == nil { + var dashResp dtos.DashboardFullWithMeta + err := json.Unmarshal(response.Body.Bytes(), &dashResp) + require.NoError(t, err) + + assert.Equal(t, DashboardUid, dashResp.Dashboard.Get("Uid").MustString()) + assert.Equal(t, false, dashResp.Meta.CanEdit) + assert.Equal(t, false, dashResp.Meta.CanDelete) + assert.Equal(t, false, dashResp.Meta.CanSave) + } else { + var errResp struct { + Error string `json:"error"` + } + err := json.Unmarshal(response.Body.Bytes(), &errResp) + require.NoError(t, err) + assert.Equal(t, test.PublicDashboardErr.Error(), errResp.Error) + } + }) + } +} + +func TestAPIGetPublicDashboardConfig(t *testing.T) { + pubdash := &PublicDashboard{IsEnabled: true} + + testCases := []struct { + Name string + DashboardUid string + ExpectedHttpResponse int + PublicDashboardResult *PublicDashboard + PublicDashboardErr error + }{ + { + Name: "retrieves public dashboard config when dashboard is found", + DashboardUid: "1", + ExpectedHttpResponse: http.StatusOK, + PublicDashboardResult: pubdash, + PublicDashboardErr: nil, + }, + { + Name: "returns 404 when dashboard not found", + DashboardUid: "77777", + ExpectedHttpResponse: http.StatusNotFound, + PublicDashboardResult: nil, + PublicDashboardErr: dashboards.ErrDashboardNotFound, + }, + { + Name: "returns 500 when internal server error", + DashboardUid: "1", + ExpectedHttpResponse: http.StatusInternalServerError, + PublicDashboardResult: nil, + PublicDashboardErr: errors.New("database broken"), + }, + } + + for _, test := range testCases { + t.Run(test.Name, func(t *testing.T) { + service := publicdashboards.NewFakePublicDashboardService(t) + service.On("GetPublicDashboardConfig", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")). + Return(test.PublicDashboardResult, test.PublicDashboardErr) + + testServer := setupTestServer( + t, + setting.NewCfg(), + buildQueryDataService(t, nil, nil, nil), + featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards), + service, + nil, + ) + + response := callAPI( + testServer, + http.MethodGet, + "/api/dashboards/uid/1/public-config", + nil, + t, + ) + + assert.Equal(t, test.ExpectedHttpResponse, response.Code) + + if response.Code == http.StatusOK { + var pdcResp PublicDashboard + err := json.Unmarshal(response.Body.Bytes(), &pdcResp) + require.NoError(t, err) + assert.Equal(t, test.PublicDashboardResult, &pdcResp) + } + }) + } +} + +func TestApiSavePublicDashboardConfig(t *testing.T) { + testCases := []struct { + Name string + DashboardUid string + publicDashboardConfig *PublicDashboard + ExpectedHttpResponse int + SaveDashboardErr error + }{ + { + Name: "returns 200 when update persists", + DashboardUid: "1", + publicDashboardConfig: &PublicDashboard{IsEnabled: true}, + ExpectedHttpResponse: http.StatusOK, + SaveDashboardErr: nil, + }, + { + Name: "returns 500 when not persisted", + ExpectedHttpResponse: http.StatusInternalServerError, + publicDashboardConfig: &PublicDashboard{}, + SaveDashboardErr: errors.New("backend failed to save"), + }, + { + Name: "returns 404 when dashboard not found", + ExpectedHttpResponse: http.StatusNotFound, + publicDashboardConfig: &PublicDashboard{}, + SaveDashboardErr: dashboards.ErrDashboardNotFound, + }, + } + + for _, test := range testCases { + t.Run(test.Name, func(t *testing.T) { + service := publicdashboards.NewFakePublicDashboardService(t) + service.On("SavePublicDashboardConfig", mock.Anything, mock.AnythingOfType("*models.SavePublicDashboardConfigDTO")). + Return(&PublicDashboard{IsEnabled: true}, test.SaveDashboardErr) + + testServer := setupTestServer( + t, + setting.NewCfg(), + buildQueryDataService(t, nil, nil, nil), + featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards), + service, + nil, + ) + + response := callAPI( + testServer, + http.MethodPost, + "/api/dashboards/uid/1/public-config", + strings.NewReader(`{ "isPublic": true }`), + t, + ) + + assert.Equal(t, test.ExpectedHttpResponse, response.Code) + + //check the result if it's a 200 + if response.Code == http.StatusOK { + val, err := json.Marshal(test.publicDashboardConfig) + require.NoError(t, err) + assert.Equal(t, string(val), response.Body.String()) + } + }) + } +} + +// `/public/dashboards/:uid/query`` endpoint test +func TestAPIQueryPublicDashboard(t *testing.T) { + cacheService := &fakeDatasources.FakeCacheService{ + DataSources: []*datasources.DataSource{ + {Uid: "mysqlds"}, + {Uid: "promds"}, + {Uid: "promds2"}, + }, + } + + // used to determine whether fakePluginClient returns an error + queryReturnsError := false + + fakePluginClient := &fakePluginClient{ + QueryDataHandlerFunc: func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + if queryReturnsError { + return nil, errors.New("error") + } + + resp := backend.Responses{} + + for _, query := range req.Queries { + resp[query.RefID] = backend.DataResponse{ + Frames: []*data.Frame{ + { + RefID: query.RefID, + Name: "query-" + query.RefID, + }, + }, + } + } + return &backend.QueryDataResponse{Responses: resp}, nil + }, + } + + qds := buildQueryDataService(t, cacheService, fakePluginClient, nil) + + setup := func(enabled bool) (*web.Mux, *publicdashboards.FakePublicDashboardService) { + service := publicdashboards.NewFakePublicDashboardService(t) + + testServer := setupTestServer( + t, + setting.NewCfg(), + qds, + featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards, enabled), + service, + nil, + ) + + return testServer, service + } + + t.Run("Status code is 404 when feature toggle is disabled", func(t *testing.T) { + server, _ := setup(false) + resp := callAPI(server, http.MethodPost, "/api/public/dashboards/abc123/panels/2/query", strings.NewReader("{}"), t) + require.Equal(t, http.StatusNotFound, resp.Code) + }) + + t.Run("Status code is 400 when the panel ID is invalid", func(t *testing.T) { + server, _ := setup(true) + resp := callAPI(server, http.MethodPost, "/api/public/dashboards/abc123/panels/notanumber/query", strings.NewReader("{}"), t) + require.Equal(t, http.StatusBadRequest, resp.Code) + }) + + t.Run("Returns query data when feature toggle is enabled", func(t *testing.T) { + server, fakeDashboardService := setup(true) + + fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil) + fakeDashboardService.On("GetPublicDashboardConfig", mock.Anything, mock.Anything, mock.Anything).Return(&PublicDashboard{}, nil) + fakeDashboardService.On("BuildAnonymousUser", mock.Anything, mock.Anything, mock.Anything).Return(&models.SignedInUser{}, nil) + fakeDashboardService.On("BuildPublicDashboardMetricRequest", mock.Anything, mock.Anything, mock.Anything, int64(2)).Return(dtos.MetricRequest{ + Queries: []*simplejson.Json{ + simplejson.MustJson([]byte(` + { + "datasource": { + "type": "prometheus", + "uid": "promds" + }, + "exemplar": true, + "expr": "query_2_A", + "interval": "", + "legendFormat": "", + "refId": "A" + } + `)), + }, + }, nil) + + resp := callAPI(server, http.MethodPost, "/api/public/dashboards/abc123/panels/2/query", strings.NewReader("{}"), t) + + require.JSONEq( + t, + `{ + "results": { + "A": { + "frames": [ + { + "data": { + "values": [] + }, + "schema": { + "fields": [], + "refId": "A", + "name": "query-A" + } + } + ] + } + } + }`, + resp.Body.String(), + ) + require.Equal(t, http.StatusOK, resp.Code) + }) + + t.Run("Status code is 500 when the query fails", func(t *testing.T) { + server, fakeDashboardService := setup(true) + + fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil) + fakeDashboardService.On("GetPublicDashboardConfig", mock.Anything, mock.Anything, mock.Anything).Return(&PublicDashboard{}, nil) + fakeDashboardService.On("BuildAnonymousUser", mock.Anything, mock.Anything, mock.Anything).Return(&models.SignedInUser{}, nil) + fakeDashboardService.On("BuildPublicDashboardMetricRequest", mock.Anything, mock.Anything, mock.Anything, int64(2)).Return(dtos.MetricRequest{ + Queries: []*simplejson.Json{ + simplejson.MustJson([]byte(` + { + "datasource": { + "type": "prometheus", + "uid": "promds" + }, + "exemplar": true, + "expr": "query_2_A", + "interval": "", + "legendFormat": "", + "refId": "A" + } + `)), + }, + }, nil) + + queryReturnsError = true + resp := callAPI(server, http.MethodPost, "/api/public/dashboards/abc123/panels/2/query", strings.NewReader("{}"), t) + require.Equal(t, http.StatusInternalServerError, resp.Code) + queryReturnsError = false + }) + + t.Run("Status code is 200 when a panel has queries from multiple datasources", func(t *testing.T) { + server, fakeDashboardService := setup(true) + + fakeDashboardService.On("GetPublicDashboard", mock.Anything, mock.Anything).Return(&models.Dashboard{}, nil) + fakeDashboardService.On("GetPublicDashboardConfig", mock.Anything, mock.Anything, mock.Anything).Return(&PublicDashboard{}, nil) + fakeDashboardService.On("BuildAnonymousUser", mock.Anything, mock.Anything, mock.Anything).Return(&models.SignedInUser{}, nil) + fakeDashboardService.On("BuildPublicDashboardMetricRequest", mock.Anything, mock.Anything, mock.Anything, int64(2)).Return(dtos.MetricRequest{ + Queries: []*simplejson.Json{ + simplejson.MustJson([]byte(` +{ + "datasource": { + "type": "prometheus", + "uid": "promds" + }, + "exemplar": true, + "expr": "query_2_A", + "interval": "", + "legendFormat": "", + "refId": "A" + } + `)), + simplejson.MustJson([]byte(` +{ + "datasource": { + "type": "prometheus", + "uid": "promds2" + }, + "exemplar": true, + "expr": "query_2_B", + "interval": "", + "legendFormat": "", + "refId": "B" + } + `)), + }, + }, nil) + + resp := callAPI(server, http.MethodPost, "/api/public/dashboards/abc123/panels/2/query", strings.NewReader("{}"), t) + require.JSONEq( + t, + `{ + "results": { + "A": { + "frames": [ + { + "data": { + "values": [] + }, + "schema": { + "fields": [], + "refId": "A", + "name": "query-A" + } + } + ] + }, + "B": { + "frames": [ + { + "data": { + "values": [] + }, + "schema": { + "fields": [], + "refId": "B", + "name": "query-B" + } + } + ] + } + } + }`, + resp.Body.String(), + ) + require.Equal(t, http.StatusOK, resp.Code) + }) +} + +func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T) { + db := sqlstore.InitTestDB(t) + + cacheService := service.ProvideCacheService(localcache.ProvideService(), db) + qds := buildQueryDataService(t, cacheService, nil, db) + + _ = db.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{ + Uid: "ds1", + OrgId: 1, + Name: "laban", + Type: datasources.DS_MYSQL, + Access: datasources.DS_ACCESS_DIRECT, + Url: "http://test", + Database: "site", + ReadOnly: true, + }) + + // Create Dashboard + saveDashboardCmd := models.SaveDashboardCommand{ + OrgId: 1, + FolderId: 1, + IsFolder: false, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": "test", + "panels": []map[string]interface{}{ + { + "id": 1, + "targets": []map[string]interface{}{ + { + "datasource": map[string]string{ + "type": "mysql", + "uid": "ds1", + }, + "refId": "A", + }, + }, + }, + }, + }), + } + + // create dashboard + dashboardStore := dashboardStore.ProvideDashboardStore(db) + dashboard, err := dashboardStore.SaveDashboard(saveDashboardCmd) + require.NoError(t, err) + + // Create public dashboard + savePubDashboardCmd := &SavePublicDashboardConfigDTO{ + DashboardUid: dashboard.Uid, + OrgId: dashboard.OrgId, + PublicDashboard: &PublicDashboard{ + IsEnabled: true, + }, + } + + // create public dashboard + store := publicdashboardsStore.ProvideStore(db) + service := publicdashboardsService.ProvideService(setting.NewCfg(), store) + pubdash, err := service.SavePublicDashboardConfig(context.Background(), savePubDashboardCmd) + require.NoError(t, err) + + // setup test server + server := setupTestServer(t, + setting.NewCfg(), + qds, + featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards), + service, + db, + ) + + resp := callAPI(server, http.MethodPost, + fmt.Sprintf("/api/public/dashboards/%s/panels/1/query", pubdash.AccessToken), + strings.NewReader(`{}`), + t, + ) + require.Equal(t, http.StatusOK, resp.Code) + require.NoError(t, err) + require.JSONEq( + t, + `{ + "results": { + "A": { + "frames": [ + { + "data": { + "values": [] + }, + "schema": { + "fields": [] + } + } + ] + } + } + }`, + resp.Body.String(), + ) +} diff --git a/pkg/services/publicdashboards/api/common_test.go b/pkg/services/publicdashboards/api/common_test.go new file mode 100644 index 00000000000..d500c8d6e90 --- /dev/null +++ b/pkg/services/publicdashboards/api/common_test.go @@ -0,0 +1,174 @@ +package api + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "golang.org/x/oauth2" + + "github.com/grafana/grafana-plugin-sdk-go/backend" + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/api/routing" + "github.com/grafana/grafana/pkg/infra/localcache" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/accesscontrol/database" + "github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol" + "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/publicdashboards" + "github.com/grafana/grafana/pkg/services/sqlstore" + + fakeDatasources "github.com/grafana/grafana/pkg/services/datasources/fakes" + datasourceService "github.com/grafana/grafana/pkg/services/datasources/service" + "github.com/grafana/grafana/pkg/services/query" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/require" +) + +type Server struct { + Mux *web.Mux + RouteRegister routing.RouteRegister + TestServer *httptest.Server +} + +func setupTestServer( + t *testing.T, + cfg *setting.Cfg, + qs *query.Service, + features *featuremgmt.FeatureManager, + service publicdashboards.Service, + db *sqlstore.SQLStore, +) *web.Mux { + // build router to register routes + rr := routing.NewRouteRegister() + + // build access control - FIXME we should be able to mock this, but to get + // tests going, we're going to instantiate full accesscontrol + //ac := accesscontrolmock.New() + //ac.WithDisabled() + + // create a sqlstore for access control. + if db == nil { + db = sqlstore.InitTestDB(t) + } + + var err error + ac, err := ossaccesscontrol.ProvideService(features, cfg, database.ProvideService(db), rr) + require.NoError(t, err) + + // build mux + m := web.New() + + // set initial context + m.Use(func(c *web.Context) { + ctx := &models.ReqContext{ + Context: c, + IsSignedIn: true, // FIXME need to be able to change this for tests + SkipCache: true, // hardcoded to make sure query service doesnt hit the cache + Logger: log.New("publicdashboards-test"), + + // Set signed in user. We might not actually need to do this. + SignedInUser: &models.SignedInUser{UserId: 1, OrgId: 1, OrgRole: models.ROLE_ADMIN, Login: "testUser"}, + } + c.Req = c.Req.WithContext(ctxkey.Set(c.Req.Context(), ctx)) + }) + + // build api, this will mount the routes at the same time if + // featuremgmt.FlagPublicDashboard is enabled + ProvideApi(service, rr, ac, qs, features) + + // connect routes to mux + rr.Register(m.Router) + + return m +} + +func callAPI(server *web.Mux, method, path string, body io.Reader, t *testing.T) *httptest.ResponseRecorder { + req, err := http.NewRequest(method, path, body) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, req) + return recorder +} + +// helper to query.Service +// allows us to stub the cache and plugin clients +func buildQueryDataService(t *testing.T, cs datasources.CacheService, fpc *fakePluginClient, store *sqlstore.SQLStore) *query.Service { + // build database if we need one + if store == nil { + store = sqlstore.InitTestDB(t) + } + + // default cache service + if cs == nil { + cs = datasourceService.ProvideCacheService(localcache.ProvideService(), store) + } + + // default fakePluginClient + if fpc == nil { + fpc = &fakePluginClient{ + QueryDataHandlerFunc: func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + resp := backend.Responses{ + "A": backend.DataResponse{ + Frames: []*data.Frame{{}}, + }, + } + return &backend.QueryDataResponse{Responses: resp}, nil + }, + } + } + + return query.ProvideService( + nil, + cs, + nil, + &fakePluginRequestValidator{}, + &fakeDatasources.FakeDataSourceService{}, + fpc, + &fakeOAuthTokenService{}, + ) +} + +//copied from pkg/api/metrics_test.go +type fakePluginRequestValidator struct { + err error +} + +func (rv *fakePluginRequestValidator) Validate(dsURL string, req *http.Request) error { + return rv.err +} + +type fakeOAuthTokenService struct { + passThruEnabled bool + token *oauth2.Token +} + +func (ts *fakeOAuthTokenService) GetCurrentOAuthToken(context.Context, *models.SignedInUser) *oauth2.Token { + return ts.token +} + +func (ts *fakeOAuthTokenService) IsOAuthPassThruEnabled(*datasources.DataSource) bool { + return ts.passThruEnabled +} + +// copied from pkg/api/plugins_test.go +type fakePluginClient struct { + plugins.Client + backend.QueryDataHandlerFunc +} + +func (c *fakePluginClient) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) { + if c.QueryDataHandlerFunc != nil { + return c.QueryDataHandlerFunc.QueryData(ctx, req) + } + + return backend.NewQueryDataResponse(), nil +} diff --git a/pkg/middleware/dashboard_public.go b/pkg/services/publicdashboards/api/middleware.go similarity index 65% rename from pkg/middleware/dashboard_public.go rename to pkg/services/publicdashboards/api/middleware.go index 3c89e8ac566..f7ba59d587a 100644 --- a/pkg/middleware/dashboard_public.go +++ b/pkg/services/publicdashboards/api/middleware.go @@ -1,8 +1,6 @@ -package middleware +package api -import ( - "github.com/grafana/grafana/pkg/models" -) +import "github.com/grafana/grafana/pkg/models" func SetPublicDashboardFlag() func(c *models.ReqContext) { return func(c *models.ReqContext) { diff --git a/pkg/services/dashboards/database/database_dashboard_public.go b/pkg/services/publicdashboards/database/database.go similarity index 53% rename from pkg/services/dashboards/database/database_dashboard_public.go rename to pkg/services/publicdashboards/database/database.go index e1ae039b1ab..d89687c1526 100644 --- a/pkg/services/dashboards/database/database_dashboard_public.go +++ b/pkg/services/publicdashboards/database/database.go @@ -3,27 +3,52 @@ package database import ( "context" + "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/publicdashboards" + . "github.com/grafana/grafana/pkg/services/publicdashboards/models" "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/util" ) -// retrieves public dashboard configuration -func (d *DashboardStore) GetPublicDashboard(ctx context.Context, accessToken string) (*models.PublicDashboard, *models.Dashboard, error) { +// Define the storage implementation. We're generating the mock implementation +// automatically +type PublicDashboardStoreImpl struct { + sqlStore *sqlstore.SQLStore + log log.Logger + dialect migrator.Dialect +} + +// Gives us a compile time error if our database does not adhere to contract of +// the interface +var _ publicdashboards.Store = (*PublicDashboardStoreImpl)(nil) + +// Factory used by wire to dependency injection +func ProvideStore(sqlStore *sqlstore.SQLStore) *PublicDashboardStoreImpl { + return &PublicDashboardStoreImpl{ + sqlStore: sqlStore, + log: log.New("publicdashboards.store"), + dialect: sqlStore.Dialect, + } +} + +// Retrieves public dashboard configuration +func (d *PublicDashboardStoreImpl) GetPublicDashboard(ctx context.Context, accessToken string) (*PublicDashboard, *models.Dashboard, error) { if accessToken == "" { - return nil, nil, dashboards.ErrPublicDashboardIdentifierNotSet + return nil, nil, ErrPublicDashboardIdentifierNotSet } // get public dashboard - pdRes := &models.PublicDashboard{AccessToken: accessToken} + pdRes := &PublicDashboard{AccessToken: accessToken} err := d.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { has, err := sess.Get(pdRes) if err != nil { return err } if !has { - return dashboards.ErrPublicDashboardNotFound + return ErrPublicDashboardNotFound } return nil }) @@ -40,7 +65,7 @@ func (d *DashboardStore) GetPublicDashboard(ctx context.Context, accessToken str return err } if !has { - return dashboards.ErrPublicDashboardNotFound + return ErrPublicDashboardNotFound } return nil }) @@ -52,15 +77,15 @@ func (d *DashboardStore) GetPublicDashboard(ctx context.Context, accessToken str return pdRes, dashRes, err } -// generates a new unique uid to retrieve a public dashboard -func (d *DashboardStore) GenerateNewPublicDashboardUid(ctx context.Context) (string, error) { +// Generates a new unique uid to retrieve a public dashboard +func (d *PublicDashboardStoreImpl) GenerateNewPublicDashboardUid(ctx context.Context) (string, error) { var uid string err := d.sqlStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { for i := 0; i < 3; i++ { uid = util.GenerateShortUID() - exists, err := sess.Get(&models.PublicDashboard{Uid: uid}) + exists, err := sess.Get(&PublicDashboard{Uid: uid}) if err != nil { return err } @@ -70,7 +95,7 @@ func (d *DashboardStore) GenerateNewPublicDashboardUid(ctx context.Context) (str } } - return dashboards.ErrPublicDashboardFailedGenerateUniqueUid + return ErrPublicDashboardFailedGenerateUniqueUid }) if err != nil { @@ -80,13 +105,13 @@ func (d *DashboardStore) GenerateNewPublicDashboardUid(ctx context.Context) (str return uid, nil } -// retrieves public dashboard configuration -func (d *DashboardStore) GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*models.PublicDashboard, error) { +// Retrieves public dashboard configuration +func (d *PublicDashboardStoreImpl) GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*PublicDashboard, error) { if dashboardUid == "" { return nil, dashboards.ErrDashboardIdentifierNotSet } - pdRes := &models.PublicDashboard{OrgId: orgId, DashboardUid: dashboardUid} + pdRes := &PublicDashboard{OrgId: orgId, DashboardUid: dashboardUid} err := d.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { // publicDashboard _, err := sess.Get(pdRes) @@ -104,8 +129,8 @@ func (d *DashboardStore) GetPublicDashboardConfig(ctx context.Context, orgId int return pdRes, err } -// persists public dashboard configuration -func (d *DashboardStore) SavePublicDashboardConfig(ctx context.Context, cmd models.SavePublicDashboardConfigCommand) (*models.PublicDashboard, error) { +// Persists public dashboard configuration +func (d *PublicDashboardStoreImpl) SavePublicDashboardConfig(ctx context.Context, cmd SavePublicDashboardConfigCommand) (*PublicDashboard, error) { err := d.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { _, err := sess.UseBool("is_enabled").Insert(&cmd.PublicDashboard) if err != nil { @@ -123,7 +148,7 @@ func (d *DashboardStore) SavePublicDashboardConfig(ctx context.Context, cmd mode } // updates existing public dashboard configuration -func (d *DashboardStore) UpdatePublicDashboardConfig(ctx context.Context, cmd models.SavePublicDashboardConfigCommand) error { +func (d *PublicDashboardStoreImpl) UpdatePublicDashboardConfig(ctx context.Context, cmd SavePublicDashboardConfigCommand) error { err := d.sqlStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { timeSettingsJSON, err := cmd.PublicDashboard.TimeSettings.MarshalJSON() if err != nil { diff --git a/pkg/services/dashboards/database/database_dashboard_public_test.go b/pkg/services/publicdashboards/database/database_test.go similarity index 62% rename from pkg/services/dashboards/database/database_dashboard_public_test.go rename to pkg/services/publicdashboards/database/database_test.go index 6462b9bf50a..ec335f13714 100644 --- a/pkg/services/dashboards/database/database_dashboard_public_test.go +++ b/pkg/services/publicdashboards/database/database_test.go @@ -10,8 +10,10 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/services/dashboards" + dashboards "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/models" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/util" ) @@ -25,19 +27,21 @@ var DefaultTime = time.Now().UTC().Round(time.Second) // GetPublicDashboard func TestIntegrationGetPublicDashboard(t *testing.T) { var sqlStore *sqlstore.SQLStore - var dashboardStore *DashboardStore + var dashboardStore *dashboardsDB.DashboardStore + var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *models.Dashboard setup := func() { sqlStore = sqlstore.InitTestDB(t) - dashboardStore = ProvideDashboardStore(sqlStore) + dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore) + publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } t.Run("returns PublicDashboard and Dashboard", func(t *testing.T) { setup() - pubdash, err := dashboardStore.SavePublicDashboardConfig(context.Background(), models.SavePublicDashboardConfigCommand{ - PublicDashboard: models.PublicDashboard{ + pubdash, err := publicdashboardStore.SavePublicDashboardConfig(context.Background(), SavePublicDashboardConfigCommand{ + PublicDashboard: PublicDashboard{ IsEnabled: true, Uid: "abc1234", DashboardUid: savedDashboard.Uid, @@ -50,7 +54,7 @@ func TestIntegrationGetPublicDashboard(t *testing.T) { }) require.NoError(t, err) - pd, d, err := dashboardStore.GetPublicDashboard(context.Background(), "NOTAREALUUID") + pd, d, err := publicdashboardStore.GetPublicDashboard(context.Background(), "NOTAREALUUID") require.NoError(t, err) assert.Equal(t, pd, pubdash) @@ -59,22 +63,22 @@ func TestIntegrationGetPublicDashboard(t *testing.T) { t.Run("returns ErrPublicDashboardNotFound with empty uid", func(t *testing.T) { setup() - _, _, err := dashboardStore.GetPublicDashboard(context.Background(), "") - require.Error(t, dashboards.ErrPublicDashboardIdentifierNotSet, err) + _, _, err := publicdashboardStore.GetPublicDashboard(context.Background(), "") + require.Error(t, ErrPublicDashboardIdentifierNotSet, err) }) t.Run("returns ErrPublicDashboardNotFound when PublicDashboard not found", func(t *testing.T) { setup() - _, _, err := dashboardStore.GetPublicDashboard(context.Background(), "zzzzzz") - require.Error(t, dashboards.ErrPublicDashboardNotFound, err) + _, _, err := publicdashboardStore.GetPublicDashboard(context.Background(), "zzzzzz") + require.Error(t, ErrPublicDashboardNotFound, err) }) t.Run("returns ErrDashboardNotFound when Dashboard not found", func(t *testing.T) { setup() - _, err := dashboardStore.SavePublicDashboardConfig(context.Background(), models.SavePublicDashboardConfigCommand{ + _, err := publicdashboardStore.SavePublicDashboardConfig(context.Background(), SavePublicDashboardConfigCommand{ DashboardUid: savedDashboard.Uid, OrgId: savedDashboard.OrgId, - PublicDashboard: models.PublicDashboard{ + PublicDashboard: PublicDashboard{ IsEnabled: true, Uid: "abc1234", DashboardUid: "nevergonnafindme", @@ -84,7 +88,7 @@ func TestIntegrationGetPublicDashboard(t *testing.T) { }, }) require.NoError(t, err) - _, _, err = dashboardStore.GetPublicDashboard(context.Background(), "abc1234") + _, _, err = publicdashboardStore.GetPublicDashboard(context.Background(), "abc1234") require.Error(t, dashboards.ErrDashboardNotFound, err) }) } @@ -92,35 +96,37 @@ func TestIntegrationGetPublicDashboard(t *testing.T) { // GetPublicDashboardConfig func TestIntegrationGetPublicDashboardConfig(t *testing.T) { var sqlStore *sqlstore.SQLStore - var dashboardStore *DashboardStore + var dashboardStore *dashboardsDB.DashboardStore + var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *models.Dashboard setup := func() { sqlStore = sqlstore.InitTestDB(t) - dashboardStore = ProvideDashboardStore(sqlStore) + dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore) + publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } t.Run("returns isPublic and set dashboardUid and orgId", func(t *testing.T) { setup() - pubdash, err := dashboardStore.GetPublicDashboardConfig(context.Background(), savedDashboard.OrgId, savedDashboard.Uid) + pubdash, err := publicdashboardStore.GetPublicDashboardConfig(context.Background(), savedDashboard.OrgId, savedDashboard.Uid) require.NoError(t, err) - assert.Equal(t, &models.PublicDashboard{IsEnabled: false, DashboardUid: savedDashboard.Uid, OrgId: savedDashboard.OrgId}, pubdash) + assert.Equal(t, &PublicDashboard{IsEnabled: false, DashboardUid: savedDashboard.Uid, OrgId: savedDashboard.OrgId}, pubdash) }) t.Run("returns dashboard errDashboardIdentifierNotSet", func(t *testing.T) { setup() - _, err := dashboardStore.GetPublicDashboardConfig(context.Background(), savedDashboard.OrgId, "") + _, err := publicdashboardStore.GetPublicDashboardConfig(context.Background(), savedDashboard.OrgId, "") require.Error(t, dashboards.ErrDashboardIdentifierNotSet, err) }) t.Run("returns isPublic along with public dashboard when exists", func(t *testing.T) { setup() // insert test public dashboard - resp, err := dashboardStore.SavePublicDashboardConfig(context.Background(), models.SavePublicDashboardConfigCommand{ + resp, err := publicdashboardStore.SavePublicDashboardConfig(context.Background(), SavePublicDashboardConfigCommand{ DashboardUid: savedDashboard.Uid, OrgId: savedDashboard.OrgId, - PublicDashboard: models.PublicDashboard{ + PublicDashboard: PublicDashboard{ IsEnabled: true, Uid: "pubdash-uid", DashboardUid: savedDashboard.Uid, @@ -132,7 +138,7 @@ func TestIntegrationGetPublicDashboardConfig(t *testing.T) { }) require.NoError(t, err) - pubdash, err := dashboardStore.GetPublicDashboardConfig(context.Background(), savedDashboard.OrgId, savedDashboard.Uid) + pubdash, err := publicdashboardStore.GetPublicDashboardConfig(context.Background(), savedDashboard.OrgId, savedDashboard.Uid) require.NoError(t, err) assert.True(t, assert.ObjectsAreEqualValues(resp, pubdash)) @@ -143,23 +149,25 @@ func TestIntegrationGetPublicDashboardConfig(t *testing.T) { // SavePublicDashboardConfig func TestIntegrationSavePublicDashboardConfig(t *testing.T) { var sqlStore *sqlstore.SQLStore - var dashboardStore *DashboardStore + var dashboardStore *dashboardsDB.DashboardStore + var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *models.Dashboard var savedDashboard2 *models.Dashboard setup := func() { sqlStore = sqlstore.InitTestDB(t, sqlstore.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagPublicDashboards}}) - dashboardStore = ProvideDashboardStore(sqlStore) + dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore) + publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) savedDashboard2 = insertTestDashboard(t, dashboardStore, "testDashie2", 1, 0, true) } t.Run("saves new public dashboard", func(t *testing.T) { setup() - resp, err := dashboardStore.SavePublicDashboardConfig(context.Background(), models.SavePublicDashboardConfigCommand{ + resp, err := publicdashboardStore.SavePublicDashboardConfig(context.Background(), SavePublicDashboardConfigCommand{ DashboardUid: savedDashboard.Uid, OrgId: savedDashboard.OrgId, - PublicDashboard: models.PublicDashboard{ + PublicDashboard: PublicDashboard{ IsEnabled: true, Uid: "pubdash-uid", DashboardUid: savedDashboard.Uid, @@ -172,7 +180,7 @@ func TestIntegrationSavePublicDashboardConfig(t *testing.T) { }) require.NoError(t, err) - pubdash, err := dashboardStore.GetPublicDashboardConfig(context.Background(), savedDashboard.OrgId, savedDashboard.Uid) + pubdash, err := publicdashboardStore.GetPublicDashboardConfig(context.Background(), savedDashboard.OrgId, savedDashboard.Uid) require.NoError(t, err) //verify saved response and queried response are the same @@ -182,7 +190,7 @@ func TestIntegrationSavePublicDashboardConfig(t *testing.T) { assert.True(t, util.IsValidShortUID(pubdash.Uid)) // verify we didn't update all dashboards - pubdash2, err := dashboardStore.GetPublicDashboardConfig(context.Background(), savedDashboard2.OrgId, savedDashboard2.Uid) + pubdash2, err := publicdashboardStore.GetPublicDashboardConfig(context.Background(), savedDashboard2.OrgId, savedDashboard2.Uid) require.NoError(t, err) assert.False(t, pubdash2.IsEnabled) }) @@ -190,13 +198,15 @@ func TestIntegrationSavePublicDashboardConfig(t *testing.T) { func TestIntegrationUpdatePublicDashboard(t *testing.T) { var sqlStore *sqlstore.SQLStore - var dashboardStore *DashboardStore + var dashboardStore *dashboardsDB.DashboardStore + var publicdashboardStore *PublicDashboardStoreImpl var savedDashboard *models.Dashboard var anotherSavedDashboard *models.Dashboard setup := func() { sqlStore = sqlstore.InitTestDB(t, sqlstore.InitTestDBOpt{FeatureFlags: []string{featuremgmt.FlagPublicDashboards}}) - dashboardStore = ProvideDashboardStore(sqlStore) + dashboardStore = dashboardsDB.ProvideDashboardStore(sqlStore) + publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) anotherSavedDashboard = insertTestDashboard(t, dashboardStore, "test another Dashie", 1, 0, true) } @@ -204,28 +214,11 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { t.Run("updates an existing dashboard", func(t *testing.T) { setup() - // inserting two different public dashboards to test update works and only affect the desired pd by uid - anotherPdUid := "anotherUid" - _, err := dashboardStore.SavePublicDashboardConfig(context.Background(), models.SavePublicDashboardConfigCommand{ - DashboardUid: anotherSavedDashboard.Uid, - OrgId: anotherSavedDashboard.OrgId, - PublicDashboard: models.PublicDashboard{ - Uid: anotherPdUid, - DashboardUid: anotherSavedDashboard.Uid, - OrgId: anotherSavedDashboard.OrgId, - IsEnabled: true, - CreatedAt: DefaultTime, - CreatedBy: 7, - AccessToken: "fakeaccesstoken", - }, - }) - require.NoError(t, err) - pdUid := "asdf1234" - _, err = dashboardStore.SavePublicDashboardConfig(context.Background(), models.SavePublicDashboardConfigCommand{ + _, err := publicdashboardStore.SavePublicDashboardConfig(context.Background(), SavePublicDashboardConfigCommand{ DashboardUid: savedDashboard.Uid, OrgId: savedDashboard.OrgId, - PublicDashboard: models.PublicDashboard{ + PublicDashboard: PublicDashboard{ Uid: pdUid, DashboardUid: savedDashboard.Uid, OrgId: savedDashboard.OrgId, @@ -237,7 +230,24 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { }) require.NoError(t, err) - updatedPublicDashboard := models.PublicDashboard{ + // inserting two different public dashboards to test update works and only affect the desired pd by uid + anotherPdUid := "anotherUid" + _, err = publicdashboardStore.SavePublicDashboardConfig(context.Background(), SavePublicDashboardConfigCommand{ + DashboardUid: anotherSavedDashboard.Uid, + OrgId: anotherSavedDashboard.OrgId, + PublicDashboard: PublicDashboard{ + Uid: anotherPdUid, + DashboardUid: anotherSavedDashboard.Uid, + OrgId: anotherSavedDashboard.OrgId, + IsEnabled: true, + CreatedAt: DefaultTime, + CreatedBy: 7, + AccessToken: "fakeaccesstoken", + }, + }) + require.NoError(t, err) + + updatedPublicDashboard := PublicDashboard{ Uid: pdUid, DashboardUid: savedDashboard.Uid, OrgId: savedDashboard.OrgId, @@ -247,7 +257,7 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { UpdatedBy: 8, } // update initial record - err = dashboardStore.UpdatePublicDashboardConfig(context.Background(), models.SavePublicDashboardConfigCommand{ + err = publicdashboardStore.UpdatePublicDashboardConfig(context.Background(), SavePublicDashboardConfigCommand{ DashboardUid: savedDashboard.Uid, OrgId: savedDashboard.OrgId, PublicDashboard: updatedPublicDashboard, @@ -255,7 +265,7 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { require.NoError(t, err) // updated dashboard should have changed - pdRetrieved, err := dashboardStore.GetPublicDashboardConfig(context.Background(), savedDashboard.OrgId, savedDashboard.Uid) + pdRetrieved, err := publicdashboardStore.GetPublicDashboardConfig(context.Background(), savedDashboard.OrgId, savedDashboard.Uid) require.NoError(t, err) assert.Equal(t, updatedPublicDashboard.UpdatedAt, pdRetrieved.UpdatedAt) @@ -264,10 +274,30 @@ func TestIntegrationUpdatePublicDashboard(t *testing.T) { assert.Equal(t, updatedPublicDashboard.IsEnabled, pdRetrieved.IsEnabled) // not updated dashboard shouldn't have changed - pdNotUpdatedRetrieved, err := dashboardStore.GetPublicDashboardConfig(context.Background(), anotherSavedDashboard.OrgId, anotherSavedDashboard.Uid) + pdNotUpdatedRetrieved, err := publicdashboardStore.GetPublicDashboardConfig(context.Background(), anotherSavedDashboard.OrgId, anotherSavedDashboard.Uid) require.NoError(t, err) assert.NotEqual(t, updatedPublicDashboard.UpdatedAt, pdNotUpdatedRetrieved.UpdatedAt) assert.NotEqual(t, updatedPublicDashboard.IsEnabled, pdNotUpdatedRetrieved.IsEnabled) }) } +func insertTestDashboard(t *testing.T, dashboardStore *dashboardsDB.DashboardStore, title string, orgId int64, + folderId int64, isFolder bool, tags ...interface{}) *models.Dashboard { + t.Helper() + cmd := models.SaveDashboardCommand{ + OrgId: orgId, + FolderId: folderId, + IsFolder: isFolder, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "id": nil, + "title": title, + "tags": tags, + }), + } + dash, err := dashboardStore.SaveDashboard(cmd) + require.NoError(t, err) + require.NotNil(t, dash) + dash.Data.Set("id", dash.Id) + dash.Data.Set("uid", dash.Uid) + return dash +} diff --git a/pkg/models/dashboards_public.go b/pkg/services/publicdashboards/models/models.go similarity index 54% rename from pkg/models/dashboards_public.go rename to pkg/services/publicdashboards/models/models.go index a05492c6ea8..31983d53088 100644 --- a/pkg/models/dashboards_public.go +++ b/pkg/services/publicdashboards/models/models.go @@ -4,6 +4,47 @@ import ( "time" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" +) + +// PublicDashboardErr represents a dashboard error. +type PublicDashboardErr struct { + StatusCode int + Status string + Reason string +} + +// Error returns the error message. +func (e PublicDashboardErr) Error() string { + if e.Reason != "" { + return e.Reason + } + return "Dashboard Error" +} + +var ( + ErrPublicDashboardFailedGenerateUniqueUid = PublicDashboardErr{ + Reason: "Failed to generate unique public dashboard id", + StatusCode: 500, + } + ErrPublicDashboardFailedGenerateAccesstoken = PublicDashboardErr{ + Reason: "Failed to public dashboard access token", + StatusCode: 500, + } + ErrPublicDashboardNotFound = PublicDashboardErr{ + Reason: "Public dashboard not found", + StatusCode: 404, + Status: "not-found", + } + ErrPublicDashboardPanelNotFound = PublicDashboardErr{ + Reason: "Panel not found in dashboard", + StatusCode: 404, + Status: "not-found", + } + ErrPublicDashboardIdentifierNotSet = PublicDashboardErr{ + Reason: "No Uid for public dashboard specified", + StatusCode: 400, + } ) type PublicDashboard struct { @@ -32,7 +73,7 @@ type TimeSettings struct { // build time settings object from json on public dashboard. If empty, use // defaults on the dashboard -func (pd PublicDashboard) BuildTimeSettings(dashboard *Dashboard) *TimeSettings { +func (pd PublicDashboard) BuildTimeSettings(dashboard *models.Dashboard) *TimeSettings { ts := &TimeSettings{ From: dashboard.Data.GetPath("time", "from").MustString(), To: dashboard.Data.GetPath("time", "to").MustString(), @@ -53,6 +94,16 @@ func (pd PublicDashboard) BuildTimeSettings(dashboard *Dashboard) *TimeSettings return ts } +// +// DTO for transforming user input in the api +// +type SavePublicDashboardConfigDTO struct { + DashboardUid string + OrgId int64 + UserId int64 + PublicDashboard *PublicDashboard +} + // // COMMANDS // diff --git a/pkg/models/dashboards_public_test.go b/pkg/services/publicdashboards/models/models_test.go similarity index 84% rename from pkg/models/dashboards_public_test.go rename to pkg/services/publicdashboards/models/models_test.go index f9b18b0b1cd..b5278c247d8 100644 --- a/pkg/models/dashboards_public_test.go +++ b/pkg/services/publicdashboards/models/models_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" "github.com/stretchr/testify/assert" ) @@ -15,13 +16,13 @@ func TestBuildTimeSettings(t *testing.T) { var dashboardData = simplejson.NewFromAny(map[string]interface{}{"time": map[string]interface{}{"from": "now-8", "to": "now"}}) testCases := []struct { name string - dashboard *Dashboard + dashboard *models.Dashboard pubdash *PublicDashboard timeResult *TimeSettings }{ { name: "should use dashboard time if pubdash time empty", - dashboard: &Dashboard{Data: dashboardData}, + dashboard: &models.Dashboard{Data: dashboardData}, pubdash: &PublicDashboard{}, timeResult: &TimeSettings{ From: "now-8", @@ -30,7 +31,7 @@ func TestBuildTimeSettings(t *testing.T) { }, { name: "should use dashboard time if pubdash to/from empty", - dashboard: &Dashboard{Data: dashboardData}, + dashboard: &models.Dashboard{Data: dashboardData}, pubdash: &PublicDashboard{}, timeResult: &TimeSettings{ From: "now-8", @@ -39,7 +40,7 @@ func TestBuildTimeSettings(t *testing.T) { }, { name: "should use pubdash time", - dashboard: &Dashboard{Data: dashboardData}, + dashboard: &models.Dashboard{Data: dashboardData}, pubdash: &PublicDashboard{TimeSettings: simplejson.NewFromAny(map[string]interface{}{"from": "now-12", "to": "now"})}, timeResult: &TimeSettings{ From: "now-12", diff --git a/pkg/services/publicdashboards/public_dashboard_service_mock.go b/pkg/services/publicdashboards/public_dashboard_service_mock.go new file mode 100644 index 00000000000..1eb14f6c5ce --- /dev/null +++ b/pkg/services/publicdashboards/public_dashboard_service_mock.go @@ -0,0 +1,144 @@ +// Code generated by mockery v2.12.2. DO NOT EDIT. + +package publicdashboards + +import ( + context "context" + + dtos "github.com/grafana/grafana/pkg/api/dtos" + mock "github.com/stretchr/testify/mock" + + models "github.com/grafana/grafana/pkg/models" + + publicdashboardsmodels "github.com/grafana/grafana/pkg/services/publicdashboards/models" + + testing "testing" +) + +// FakePublicDashboardService is an autogenerated mock type for the Service type +type FakePublicDashboardService struct { + mock.Mock +} + +// BuildAnonymousUser provides a mock function with given fields: ctx, dashboard +func (_m *FakePublicDashboardService) BuildAnonymousUser(ctx context.Context, dashboard *models.Dashboard) (*models.SignedInUser, error) { + ret := _m.Called(ctx, dashboard) + + var r0 *models.SignedInUser + if rf, ok := ret.Get(0).(func(context.Context, *models.Dashboard) *models.SignedInUser); ok { + r0 = rf(ctx, dashboard) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.SignedInUser) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *models.Dashboard) error); ok { + r1 = rf(ctx, dashboard) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// BuildPublicDashboardMetricRequest provides a mock function with given fields: ctx, dashboard, publicDashboard, panelId +func (_m *FakePublicDashboardService) BuildPublicDashboardMetricRequest(ctx context.Context, dashboard *models.Dashboard, publicDashboard *publicdashboardsmodels.PublicDashboard, panelId int64) (dtos.MetricRequest, error) { + ret := _m.Called(ctx, dashboard, publicDashboard, panelId) + + var r0 dtos.MetricRequest + if rf, ok := ret.Get(0).(func(context.Context, *models.Dashboard, *publicdashboardsmodels.PublicDashboard, int64) dtos.MetricRequest); ok { + r0 = rf(ctx, dashboard, publicDashboard, panelId) + } else { + r0 = ret.Get(0).(dtos.MetricRequest) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *models.Dashboard, *publicdashboardsmodels.PublicDashboard, int64) error); ok { + r1 = rf(ctx, dashboard, publicDashboard, panelId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetPublicDashboard provides a mock function with given fields: ctx, accessToken +func (_m *FakePublicDashboardService) GetPublicDashboard(ctx context.Context, accessToken string) (*models.Dashboard, error) { + ret := _m.Called(ctx, accessToken) + + var r0 *models.Dashboard + if rf, ok := ret.Get(0).(func(context.Context, string) *models.Dashboard); ok { + r0 = rf(ctx, accessToken) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Dashboard) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, accessToken) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetPublicDashboardConfig provides a mock function with given fields: ctx, orgId, dashboardUid +func (_m *FakePublicDashboardService) GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*publicdashboardsmodels.PublicDashboard, error) { + ret := _m.Called(ctx, orgId, dashboardUid) + + var r0 *publicdashboardsmodels.PublicDashboard + if rf, ok := ret.Get(0).(func(context.Context, int64, string) *publicdashboardsmodels.PublicDashboard); ok { + r0 = rf(ctx, orgId, dashboardUid) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*publicdashboardsmodels.PublicDashboard) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int64, string) error); ok { + r1 = rf(ctx, orgId, dashboardUid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SavePublicDashboardConfig provides a mock function with given fields: ctx, dto +func (_m *FakePublicDashboardService) SavePublicDashboardConfig(ctx context.Context, dto *publicdashboardsmodels.SavePublicDashboardConfigDTO) (*publicdashboardsmodels.PublicDashboard, error) { + ret := _m.Called(ctx, dto) + + var r0 *publicdashboardsmodels.PublicDashboard + if rf, ok := ret.Get(0).(func(context.Context, *publicdashboardsmodels.SavePublicDashboardConfigDTO) *publicdashboardsmodels.PublicDashboard); ok { + r0 = rf(ctx, dto) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*publicdashboardsmodels.PublicDashboard) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *publicdashboardsmodels.SavePublicDashboardConfigDTO) error); ok { + r1 = rf(ctx, dto) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewFakePublicDashboardService creates a new instance of FakePublicDashboardService. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations. +func NewFakePublicDashboardService(t testing.TB) *FakePublicDashboardService { + mock := &FakePublicDashboardService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/services/publicdashboards/public_dashboard_store_mock.go b/pkg/services/publicdashboards/public_dashboard_store_mock.go new file mode 100644 index 00000000000..20102fc1179 --- /dev/null +++ b/pkg/services/publicdashboards/public_dashboard_store_mock.go @@ -0,0 +1,142 @@ +// Code generated by mockery v2.12.2. DO NOT EDIT. + +package publicdashboards + +import ( + context "context" + + models "github.com/grafana/grafana/pkg/services/publicdashboards/models" + mock "github.com/stretchr/testify/mock" + + pkgmodels "github.com/grafana/grafana/pkg/models" + + testing "testing" +) + +// FakePublicDashboardStore is an autogenerated mock type for the Store type +type FakePublicDashboardStore struct { + mock.Mock +} + +// GenerateNewPublicDashboardUid provides a mock function with given fields: ctx +func (_m *FakePublicDashboardStore) GenerateNewPublicDashboardUid(ctx context.Context) (string, error) { + ret := _m.Called(ctx) + + var r0 string + if rf, ok := ret.Get(0).(func(context.Context) string); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(string) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetPublicDashboard provides a mock function with given fields: ctx, accessToken +func (_m *FakePublicDashboardStore) GetPublicDashboard(ctx context.Context, accessToken string) (*models.PublicDashboard, *pkgmodels.Dashboard, error) { + ret := _m.Called(ctx, accessToken) + + var r0 *models.PublicDashboard + if rf, ok := ret.Get(0).(func(context.Context, string) *models.PublicDashboard); ok { + r0 = rf(ctx, accessToken) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.PublicDashboard) + } + } + + var r1 *pkgmodels.Dashboard + if rf, ok := ret.Get(1).(func(context.Context, string) *pkgmodels.Dashboard); ok { + r1 = rf(ctx, accessToken) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*pkgmodels.Dashboard) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func(context.Context, string) error); ok { + r2 = rf(ctx, accessToken) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// GetPublicDashboardConfig provides a mock function with given fields: ctx, orgId, dashboardUid +func (_m *FakePublicDashboardStore) GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*models.PublicDashboard, error) { + ret := _m.Called(ctx, orgId, dashboardUid) + + var r0 *models.PublicDashboard + 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) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int64, string) error); ok { + r1 = rf(ctx, orgId, dashboardUid) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SavePublicDashboardConfig provides a mock function with given fields: ctx, cmd +func (_m *FakePublicDashboardStore) SavePublicDashboardConfig(ctx context.Context, cmd models.SavePublicDashboardConfigCommand) (*models.PublicDashboard, error) { + ret := _m.Called(ctx, cmd) + + var r0 *models.PublicDashboard + if rf, ok := ret.Get(0).(func(context.Context, models.SavePublicDashboardConfigCommand) *models.PublicDashboard); ok { + r0 = rf(ctx, cmd) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.PublicDashboard) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, models.SavePublicDashboardConfigCommand) error); ok { + r1 = rf(ctx, cmd) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// UpdatePublicDashboardConfig provides a mock function with given fields: ctx, cmd +func (_m *FakePublicDashboardStore) UpdatePublicDashboardConfig(ctx context.Context, cmd models.SavePublicDashboardConfigCommand) error { + ret := _m.Called(ctx, cmd) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, models.SavePublicDashboardConfigCommand) error); ok { + r0 = rf(ctx, cmd) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewFakePublicDashboardStore creates a new instance of FakePublicDashboardStore. It also registers the testing.TB interface on the mock and a cleanup function to assert the mocks expectations. +func NewFakePublicDashboardStore(t testing.TB) *FakePublicDashboardStore { + mock := &FakePublicDashboardStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/services/publicdashboards/publicdashboard.go b/pkg/services/publicdashboards/publicdashboard.go new file mode 100644 index 00000000000..4d3be2b3fa7 --- /dev/null +++ b/pkg/services/publicdashboards/publicdashboard.go @@ -0,0 +1,29 @@ +package publicdashboards + +import ( + "context" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/models" + . "github.com/grafana/grafana/pkg/services/publicdashboards/models" +) + +// These are the api contracts. The API should match the underlying service and store + +//go:generate mockery --name Service --structname FakePublicDashboardService --inpackage --filename public_dashboard_service_mock.go +type Service interface { + BuildAnonymousUser(ctx context.Context, dashboard *models.Dashboard) (*models.SignedInUser, error) + GetPublicDashboard(ctx context.Context, accessToken string) (*models.Dashboard, error) + GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*PublicDashboard, error) + SavePublicDashboardConfig(ctx context.Context, dto *SavePublicDashboardConfigDTO) (*PublicDashboard, error) + BuildPublicDashboardMetricRequest(ctx context.Context, dashboard *models.Dashboard, publicDashboard *PublicDashboard, panelId int64) (dtos.MetricRequest, error) +} + +//go:generate mockery --name Store --structname FakePublicDashboardStore --inpackage --filename public_dashboard_store_mock.go +type Store interface { + GetPublicDashboard(ctx context.Context, accessToken string) (*PublicDashboard, *models.Dashboard, error) + GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*PublicDashboard, error) + GenerateNewPublicDashboardUid(ctx context.Context) (string, error) + SavePublicDashboardConfig(ctx context.Context, cmd SavePublicDashboardConfigCommand) (*PublicDashboard, error) + UpdatePublicDashboardConfig(ctx context.Context, cmd SavePublicDashboardConfigCommand) error +} diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go new file mode 100644 index 00000000000..c1f69db5463 --- /dev/null +++ b/pkg/services/publicdashboards/service/service.go @@ -0,0 +1,200 @@ +package service + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/publicdashboards" + . "github.com/grafana/grafana/pkg/services/publicdashboards/models" + "github.com/grafana/grafana/pkg/setting" +) + +// Define the Service Implementation. We're generating mock implementation +// automatically +type PublicDashboardServiceImpl struct { + log log.Logger + cfg *setting.Cfg + store publicdashboards.Store +} + +// Gives us compile time error if the service does not adhere to the contract of +// the interface +var _ publicdashboards.Service = (*PublicDashboardServiceImpl)(nil) + +// Factory for method used by wire to inject dependencies. +// builds the service, and api, and configures routes +func ProvideService( + cfg *setting.Cfg, + store publicdashboards.Store, +) *PublicDashboardServiceImpl { + return &PublicDashboardServiceImpl{ + log: log.New("publicdashboards"), + cfg: cfg, + store: store, + } +} + +// Gets public dashboard via access token +func (pd *PublicDashboardServiceImpl) GetPublicDashboard(ctx context.Context, accessToken string) (*models.Dashboard, error) { + pubdash, d, err := pd.store.GetPublicDashboard(ctx, accessToken) + + if err != nil { + return nil, err + } + + if pubdash == nil || d == nil { + return nil, ErrPublicDashboardNotFound + } + + if !pubdash.IsEnabled { + return nil, ErrPublicDashboardNotFound + } + + ts := pubdash.BuildTimeSettings(d) + d.Data.SetPath([]string{"time", "from"}, ts.From) + d.Data.SetPath([]string{"time", "to"}, ts.To) + + return d, nil +} + +// GetPublicDashboardConfig is a helper method to retrieve the public dashboard configuration for a given dashboard from the database +func (pd *PublicDashboardServiceImpl) GetPublicDashboardConfig(ctx context.Context, orgId int64, dashboardUid string) (*PublicDashboard, error) { + pdc, err := pd.store.GetPublicDashboardConfig(ctx, orgId, dashboardUid) + if err != nil { + return nil, err + } + + return pdc, nil +} + +// SavePublicDashboardConfig is a helper method to persist the sharing config +// to the database. It handles validations for sharing config and persistence +func (pd *PublicDashboardServiceImpl) SavePublicDashboardConfig(ctx context.Context, dto *SavePublicDashboardConfigDTO) (*PublicDashboard, error) { + if len(dto.DashboardUid) == 0 { + return nil, dashboards.ErrDashboardIdentifierNotSet + } + + // set default value for time settings + if dto.PublicDashboard.TimeSettings == nil { + dto.PublicDashboard.TimeSettings = simplejson.New() + } + + if dto.PublicDashboard.Uid == "" { + return pd.savePublicDashboardConfig(ctx, dto) + } + + return pd.updatePublicDashboardConfig(ctx, dto) +} + +func (pd *PublicDashboardServiceImpl) savePublicDashboardConfig(ctx context.Context, dto *SavePublicDashboardConfigDTO) (*PublicDashboard, error) { + uid, err := pd.store.GenerateNewPublicDashboardUid(ctx) + if err != nil { + return nil, err + } + + accessToken, err := GenerateAccessToken() + if err != nil { + return nil, err + } + + cmd := SavePublicDashboardConfigCommand{ + DashboardUid: dto.DashboardUid, + OrgId: dto.OrgId, + PublicDashboard: PublicDashboard{ + Uid: uid, + DashboardUid: dto.DashboardUid, + OrgId: dto.OrgId, + IsEnabled: dto.PublicDashboard.IsEnabled, + TimeSettings: dto.PublicDashboard.TimeSettings, + CreatedBy: dto.UserId, + CreatedAt: time.Now(), + AccessToken: accessToken, + }, + } + + return pd.store.SavePublicDashboardConfig(ctx, cmd) +} + +func (pd *PublicDashboardServiceImpl) updatePublicDashboardConfig(ctx context.Context, dto *SavePublicDashboardConfigDTO) (*PublicDashboard, error) { + cmd := SavePublicDashboardConfigCommand{ + PublicDashboard: PublicDashboard{ + Uid: dto.PublicDashboard.Uid, + IsEnabled: dto.PublicDashboard.IsEnabled, + TimeSettings: dto.PublicDashboard.TimeSettings, + UpdatedBy: dto.UserId, + UpdatedAt: time.Now(), + }, + } + + err := pd.store.UpdatePublicDashboardConfig(ctx, cmd) + if err != nil { + return nil, err + } + + publicDashboard, err := pd.store.GetPublicDashboardConfig(ctx, dto.OrgId, dto.DashboardUid) + if err != nil { + return nil, err + } + + return publicDashboard, nil +} + +// BuildPublicDashboardMetricRequest merges public dashboard parameters with +// dashboard and returns a metrics request to be sent to query backend +func (pd *PublicDashboardServiceImpl) BuildPublicDashboardMetricRequest(ctx context.Context, dashboard *models.Dashboard, publicDashboard *PublicDashboard, panelId int64) (dtos.MetricRequest, error) { + if !publicDashboard.IsEnabled { + return dtos.MetricRequest{}, ErrPublicDashboardNotFound + } + + queriesByPanel := models.GroupQueriesByPanelId(dashboard.Data) + + if _, ok := queriesByPanel[panelId]; !ok { + return dtos.MetricRequest{}, ErrPublicDashboardPanelNotFound + } + + ts := publicDashboard.BuildTimeSettings(dashboard) + + return dtos.MetricRequest{ + From: ts.From, + To: ts.To, + Queries: queriesByPanel[panelId], + }, nil +} + +// BuildAnonymousUser creates a user with permissions to read from all datasources used in the dashboard +func (pd *PublicDashboardServiceImpl) BuildAnonymousUser(ctx context.Context, dashboard *models.Dashboard) (*models.SignedInUser, error) { + datasourceUids := models.GetUniqueDashboardDatasourceUids(dashboard.Data) + + // Create a temp user with read-only datasource permissions + anonymousUser := &models.SignedInUser{OrgId: dashboard.OrgId, Permissions: make(map[int64]map[string][]string)} + permissions := make(map[string][]string) + queryScopes := make([]string, 0) + readScopes := make([]string, 0) + for _, uid := range datasourceUids { + queryScopes = append(queryScopes, fmt.Sprintf("datasources:uid:%s", uid)) + readScopes = append(readScopes, fmt.Sprintf("datasources:uid:%s", uid)) + } + permissions[datasources.ActionQuery] = queryScopes + permissions[datasources.ActionRead] = readScopes + anonymousUser.Permissions[dashboard.OrgId] = permissions + + return anonymousUser, nil +} + +// generates a uuid formatted without dashes to use as access token +func GenerateAccessToken() (string, error) { + token, err := uuid.NewRandom() + if err != nil { + return "", err + } + + return fmt.Sprintf("%x", token[:]), nil +} diff --git a/pkg/services/dashboards/service/dashboard_public_test.go b/pkg/services/publicdashboards/service/service_test.go similarity index 75% rename from pkg/services/dashboards/service/dashboard_public_test.go rename to pkg/services/publicdashboards/service/service_test.go index 74e2efd62f3..8198d41f4fa 100644 --- a/pkg/services/dashboards/service/dashboard_public_test.go +++ b/pkg/services/publicdashboards/service/service_test.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/models" - "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/dashboards/database" + dashboardsDB "github.com/grafana/grafana/pkg/services/dashboards/database" + . "github.com/grafana/grafana/pkg/services/publicdashboards" + database "github.com/grafana/grafana/pkg/services/publicdashboards/database" + . "github.com/grafana/grafana/pkg/services/publicdashboards/models" "github.com/grafana/grafana/pkg/services/sqlstore" ) @@ -25,7 +27,7 @@ var mergedDashboardData = simplejson.NewFromAny(map[string]interface{}{"time": m func TestGetPublicDashboard(t *testing.T) { type storeResp struct { - pd *models.PublicDashboard + pd *PublicDashboard d *models.Dashboard err error } @@ -41,7 +43,7 @@ func TestGetPublicDashboard(t *testing.T) { Name: "returns a dashboard", AccessToken: "abc123", StoreResp: &storeResp{ - pd: &models.PublicDashboard{IsEnabled: true}, + pd: &PublicDashboard{IsEnabled: true}, d: &models.Dashboard{Uid: "mydashboard", Data: dashboardData}, err: nil, }, @@ -52,7 +54,7 @@ func TestGetPublicDashboard(t *testing.T) { Name: "puts pubdash time settings into dashboard", AccessToken: "abc123", StoreResp: &storeResp{ - pd: &models.PublicDashboard{IsEnabled: true, TimeSettings: timeSettings}, + pd: &PublicDashboard{IsEnabled: true, TimeSettings: timeSettings}, d: &models.Dashboard{Data: dashboardData}, err: nil, }, @@ -63,35 +65,35 @@ func TestGetPublicDashboard(t *testing.T) { Name: "returns ErrPublicDashboardNotFound when isEnabled is false", AccessToken: "abc123", StoreResp: &storeResp{ - pd: &models.PublicDashboard{IsEnabled: false}, + pd: &PublicDashboard{IsEnabled: false}, d: &models.Dashboard{Uid: "mydashboard"}, err: nil, }, - ErrResp: dashboards.ErrPublicDashboardNotFound, + ErrResp: ErrPublicDashboardNotFound, DashResp: nil, }, { Name: "returns ErrPublicDashboardNotFound if PublicDashboard missing", AccessToken: "abc123", StoreResp: &storeResp{pd: nil, d: nil, err: nil}, - ErrResp: dashboards.ErrPublicDashboardNotFound, + ErrResp: ErrPublicDashboardNotFound, DashResp: nil, }, { Name: "returns ErrPublicDashboardNotFound if Dashboard missing", AccessToken: "abc123", StoreResp: &storeResp{pd: nil, d: nil, err: nil}, - ErrResp: dashboards.ErrPublicDashboardNotFound, + ErrResp: ErrPublicDashboardNotFound, DashResp: nil, }, } for _, test := range testCases { t.Run(test.Name, func(t *testing.T) { - fakeStore := dashboards.FakeDashboardStore{} - service := &DashboardServiceImpl{ - log: log.New("test.logger"), - dashboardStore: &fakeStore, + fakeStore := FakePublicDashboardStore{} + service := &PublicDashboardServiceImpl{ + log: log.New("test.logger"), + store: &fakeStore, } fakeStore.On("GetPublicDashboard", mock.Anything, mock.Anything). @@ -116,19 +118,20 @@ func TestGetPublicDashboard(t *testing.T) { func TestSavePublicDashboard(t *testing.T) { t.Run("Saving public dashboard", func(t *testing.T) { sqlStore := sqlstore.InitTestDB(t) - dashboardStore := database.ProvideDashboardStore(sqlStore) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore) + publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) - service := &DashboardServiceImpl{ - log: log.New("test.logger"), - dashboardStore: dashboardStore, + service := &PublicDashboardServiceImpl{ + log: log.New("test.logger"), + store: publicdashboardStore, } - dto := &dashboards.SavePublicDashboardConfigDTO{ + dto := &SavePublicDashboardConfigDTO{ DashboardUid: dashboard.Uid, OrgId: dashboard.OrgId, UserId: 7, - PublicDashboard: &models.PublicDashboard{ + PublicDashboard: &PublicDashboard{ IsEnabled: true, DashboardUid: "NOTTHESAME", OrgId: 9999999, @@ -159,19 +162,20 @@ func TestSavePublicDashboard(t *testing.T) { t.Run("Validate pubdash has default time setting value", func(t *testing.T) { sqlStore := sqlstore.InitTestDB(t) - dashboardStore := database.ProvideDashboardStore(sqlStore) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore) + publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) - service := &DashboardServiceImpl{ - log: log.New("test.logger"), - dashboardStore: dashboardStore, + service := &PublicDashboardServiceImpl{ + log: log.New("test.logger"), + store: publicdashboardStore, } - dto := &dashboards.SavePublicDashboardConfigDTO{ + dto := &SavePublicDashboardConfigDTO{ DashboardUid: dashboard.Uid, OrgId: dashboard.OrgId, UserId: 7, - PublicDashboard: &models.PublicDashboard{ + PublicDashboard: &PublicDashboard{ IsEnabled: true, DashboardUid: "NOTTHESAME", OrgId: 9999999, @@ -192,19 +196,20 @@ func TestSavePublicDashboard(t *testing.T) { func TestUpdatePublicDashboard(t *testing.T) { t.Run("Updating public dashboard", func(t *testing.T) { sqlStore := sqlstore.InitTestDB(t) - dashboardStore := database.ProvideDashboardStore(sqlStore) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore) + publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) - service := &DashboardServiceImpl{ - log: log.New("test.logger"), - dashboardStore: dashboardStore, + service := &PublicDashboardServiceImpl{ + log: log.New("test.logger"), + store: publicdashboardStore, } - dto := &dashboards.SavePublicDashboardConfigDTO{ + dto := &SavePublicDashboardConfigDTO{ DashboardUid: dashboard.Uid, OrgId: dashboard.OrgId, UserId: 7, - PublicDashboard: &models.PublicDashboard{ + PublicDashboard: &PublicDashboard{ IsEnabled: true, TimeSettings: timeSettings, }, @@ -217,11 +222,11 @@ func TestUpdatePublicDashboard(t *testing.T) { require.NoError(t, err) // attempt to overwrite settings - dto = &dashboards.SavePublicDashboardConfigDTO{ + dto = &SavePublicDashboardConfigDTO{ DashboardUid: dashboard.Uid, OrgId: dashboard.OrgId, UserId: 8, - PublicDashboard: &models.PublicDashboard{ + PublicDashboard: &PublicDashboard{ Uid: savedPubdash.Uid, OrgId: 9, DashboardUid: "abc1234", @@ -258,19 +263,20 @@ func TestUpdatePublicDashboard(t *testing.T) { t.Run("Updating set empty time settings", func(t *testing.T) { sqlStore := sqlstore.InitTestDB(t) - dashboardStore := database.ProvideDashboardStore(sqlStore) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore) + publicdashboardStore := database.ProvideStore(sqlStore) dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) - service := &DashboardServiceImpl{ - log: log.New("test.logger"), - dashboardStore: dashboardStore, + service := &PublicDashboardServiceImpl{ + log: log.New("test.logger"), + store: publicdashboardStore, } - dto := &dashboards.SavePublicDashboardConfigDTO{ + dto := &SavePublicDashboardConfigDTO{ DashboardUid: dashboard.Uid, OrgId: dashboard.OrgId, UserId: 7, - PublicDashboard: &models.PublicDashboard{ + PublicDashboard: &PublicDashboard{ IsEnabled: true, TimeSettings: timeSettings, }, @@ -285,11 +291,11 @@ func TestUpdatePublicDashboard(t *testing.T) { require.NoError(t, err) // attempt to overwrite settings - dto = &dashboards.SavePublicDashboardConfigDTO{ + dto = &SavePublicDashboardConfigDTO{ DashboardUid: dashboard.Uid, OrgId: dashboard.OrgId, UserId: 8, - PublicDashboard: &models.PublicDashboard{ + PublicDashboard: &PublicDashboard{ Uid: savedPubdash.Uid, OrgId: 9, DashboardUid: "abc1234", @@ -314,21 +320,44 @@ func TestUpdatePublicDashboard(t *testing.T) { }) } +func TestBuildAnonymousUser(t *testing.T) { + sqlStore := sqlstore.InitTestDB(t) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore) + dashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) + publicdashboardStore := database.ProvideStore(sqlStore) + service := &PublicDashboardServiceImpl{ + log: log.New("test.logger"), + store: publicdashboardStore, + } + + t.Run("will add datasource read and query permissions to user for each datasource in dashboard", func(t *testing.T) { + user, err := service.BuildAnonymousUser(context.Background(), dashboard) + require.NoError(t, err) + require.Equal(t, dashboard.OrgId, user.OrgId) + require.Equal(t, "datasources:uid:ds1", user.Permissions[user.OrgId]["datasources:query"][0]) + require.Equal(t, "datasources:uid:ds3", user.Permissions[user.OrgId]["datasources:query"][1]) + require.Equal(t, "datasources:uid:ds1", user.Permissions[user.OrgId]["datasources:read"][0]) + require.Equal(t, "datasources:uid:ds3", user.Permissions[user.OrgId]["datasources:read"][1]) + }) +} + func TestBuildPublicDashboardMetricRequest(t *testing.T) { sqlStore := sqlstore.InitTestDB(t) - dashboardStore := database.ProvideDashboardStore(sqlStore) + dashboardStore := dashboardsDB.ProvideDashboardStore(sqlStore) + publicdashboardStore := database.ProvideStore(sqlStore) + publicDashboard := insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) nonPublicDashboard := insertTestDashboard(t, dashboardStore, "testNonPublicDashie", 1, 0, true) - service := &DashboardServiceImpl{ - log: log.New("test.logger"), - dashboardStore: dashboardStore, + service := &PublicDashboardServiceImpl{ + log: log.New("test.logger"), + store: publicdashboardStore, } - dto := &dashboards.SavePublicDashboardConfigDTO{ + dto := &SavePublicDashboardConfigDTO{ DashboardUid: publicDashboard.Uid, OrgId: publicDashboard.OrgId, - PublicDashboard: &models.PublicDashboard{ + PublicDashboard: &PublicDashboard{ IsEnabled: true, DashboardUid: "NOTTHESAME", OrgId: 9999999, @@ -339,10 +368,10 @@ func TestBuildPublicDashboardMetricRequest(t *testing.T) { publicDashboardPD, err := service.SavePublicDashboardConfig(context.Background(), dto) require.NoError(t, err) - nonPublicDto := &dashboards.SavePublicDashboardConfigDTO{ + nonPublicDto := &SavePublicDashboardConfigDTO{ DashboardUid: nonPublicDashboard.Uid, OrgId: nonPublicDashboard.OrgId, - PublicDashboard: &models.PublicDashboard{ + PublicDashboard: &PublicDashboard{ IsEnabled: false, DashboardUid: "NOTTHESAME", OrgId: 9999999, @@ -411,7 +440,7 @@ func TestBuildPublicDashboardMetricRequest(t *testing.T) { }) } -func insertTestDashboard(t *testing.T, dashboardStore *database.DashboardStore, title string, orgId int64, +func insertTestDashboard(t *testing.T, dashboardStore *dashboardsDB.DashboardStore, title string, orgId int64, folderId int64, isFolder bool, tags ...interface{}) *models.Dashboard { t.Helper() cmd := models.SaveDashboardCommand{ @@ -425,6 +454,9 @@ func insertTestDashboard(t *testing.T, dashboardStore *database.DashboardStore, "panels": []interface{}{ map[string]interface{}{ "id": 1, + "datasource": map[string]interface{}{ + "uid": "ds1", + }, "targets": []interface{}{ map[string]interface{}{ "datasource": map[string]interface{}{ @@ -444,6 +476,9 @@ func insertTestDashboard(t *testing.T, dashboardStore *database.DashboardStore, }, map[string]interface{}{ "id": 2, + "datasource": map[string]interface{}{ + "uid": "ds3", + }, "targets": []interface{}{ map[string]interface{}{ "datasource": map[string]interface{}{ diff --git a/pkg/services/searchV2/extract/types.go b/pkg/services/searchV2/extract/types.go index 71ff5545bb1..9b084cd2409 100644 --- a/pkg/services/searchV2/extract/types.go +++ b/pkg/services/searchV2/extract/types.go @@ -22,6 +22,7 @@ type PanelInfo struct { } type DashboardInfo struct { + UID string `json:"uid,omitempty"` ID int64 `json:"id,omitempty"` // internal ID Title string `json:"title"` Description string `json:"description,omitempty"` diff --git a/pkg/services/secrets/manager/manager.go b/pkg/services/secrets/manager/manager.go index 03cb18ede29..1c3c391f01e 100644 --- a/pkg/services/secrets/manager/manager.go +++ b/pkg/services/secrets/manager/manager.go @@ -34,7 +34,10 @@ type SecretsService struct { mtx sync.Mutex dataKeyCache *dataKeyCache - providers map[secrets.ProviderID]secrets.Provider + pOnce sync.Once + providers map[secrets.ProviderID]secrets.Provider + kmsProvidersService kmsproviders.Service + currentProviderID secrets.ProviderID log log.Logger @@ -48,46 +51,56 @@ func ProvideSecretsService( features featuremgmt.FeatureToggles, usageStats usagestats.Service, ) (*SecretsService, error) { - providers, err := kmsProvidersService.Provide() - if err != nil { - return nil, err - } + ttl := settings.KeyValue("security.encryption", "data_keys_cache_ttl").MustDuration(15 * time.Minute) - logger := log.New("secrets") - enabled := !features.IsEnabled(featuremgmt.FlagDisableEnvelopeEncryption) currentProviderID := kmsproviders.NormalizeProviderID(secrets.ProviderID( settings.KeyValue("security", "encryption_provider").MustString(kmsproviders.Default), )) - if _, ok := providers[currentProviderID]; enabled && !ok { + s := &SecretsService{ + store: store, + enc: enc, + settings: settings, + usageStats: usageStats, + kmsProvidersService: kmsProvidersService, + dataKeyCache: newDataKeyCache(ttl), + currentProviderID: currentProviderID, + features: features, + log: log.New("secrets"), + } + + enabled := !features.IsEnabled(featuremgmt.FlagDisableEnvelopeEncryption) + + if enabled { + err := s.InitProviders() + if err != nil { + return nil, err + } + } + + if _, ok := s.providers[currentProviderID]; enabled && !ok { return nil, fmt.Errorf("missing configuration for current encryption provider %s", currentProviderID) } if !enabled && currentProviderID != kmsproviders.Default { - logger.Warn("Changing encryption provider requires enabling envelope encryption feature") + s.log.Warn("Changing encryption provider requires enabling envelope encryption feature") } - logger.Info("Envelope encryption state", "enabled", enabled, "current provider", currentProviderID) - - ttl := settings.KeyValue("security.encryption", "data_keys_cache_ttl").MustDuration(15 * time.Minute) - - s := &SecretsService{ - store: store, - enc: enc, - settings: settings, - usageStats: usageStats, - providers: providers, - dataKeyCache: newDataKeyCache(ttl), - currentProviderID: currentProviderID, - features: features, - log: logger, - } + s.log.Info("Envelope encryption state", "enabled", enabled, "current provider", currentProviderID) s.registerUsageMetrics() return s, nil } +func (s *SecretsService) InitProviders() (err error) { + s.pOnce.Do(func() { + s.providers, err = s.kmsProvidersService.Provide() + }) + + return +} + func (s *SecretsService) registerUsageMetrics() { s.usageStats.RegisterMetricsFunc(func(context.Context) (map[string]interface{}, error) { usageMetrics := make(map[string]interface{}) diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index b303b031a51..dce533502c5 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/models" ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/serviceaccounts" ) type TeamStore interface { @@ -528,17 +529,25 @@ func (ss *SQLStore) GetTeamMembers(ctx context.Context, query *models.GetTeamMem // If the signed in user is not set no member will be returned if !ac.IsDisabled(ss.Cfg) { sqlID := fmt.Sprintf("%s.%s", ss.engine.Dialect().Quote("user"), ss.engine.Dialect().Quote("id")) + var filter ac.SQLFilter *acFilter, err = ac.Filter(query.SignedInUser, sqlID, "users:id:", ac.ActionOrgUsersRead) if err != nil { return err } + + filter, err = ac.Filter(query.SignedInUser, sqlID, "serviceaccounts:id:", serviceaccounts.ActionRead) + if err != nil { + return err + } + acFilter.Where = fmt.Sprintf("(%s OR %s)", acFilter.Where, filter.Where) + acFilter.Args = append(acFilter.Args, filter.Args...) } return ss.getTeamMembers(ctx, query, acFilter) } // getTeamMembers return a list of members for the specified team -func (ss *SQLStore) getTeamMembers(ctx context.Context, query *models.GetTeamMembersQuery, acUserFilter *ac.SQLFilter) error { +func (ss *SQLStore) getTeamMembers(ctx context.Context, query *models.GetTeamMembersQuery, acFilter *ac.SQLFilter) error { return ss.WithDbSession(ctx, func(dbSess *DBSession) error { query.Result = make([]*models.TeamMemberDTO, 0) sess := dbSess.Table("team_member") @@ -546,11 +555,8 @@ func (ss *SQLStore) getTeamMembers(ctx context.Context, query *models.GetTeamMem fmt.Sprintf("team_member.user_id=%s.%s", ss.Dialect.Quote("user"), ss.Dialect.Quote("id")), ) - // explicitly check for serviceaccounts - sess.Where(fmt.Sprintf("%s.is_service_account=?", ss.Dialect.Quote("user")), ss.Dialect.BooleanStr(false)) - - if acUserFilter != nil { - sess.Where(acUserFilter.Where, acUserFilter.Args...) + if acFilter != nil { + sess.Where(acFilter.Where, acFilter.Args...) } // Join with only most recent auth module diff --git a/pkg/services/sqlstore/team_test.go b/pkg/services/sqlstore/team_test.go index 845b0fddf3d..2726ad0e2ef 100644 --- a/pkg/services/sqlstore/team_test.go +++ b/pkg/services/sqlstore/team_test.go @@ -24,9 +24,8 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { OrgId: 1, Permissions: map[int64]map[string][]string{ 1: { - ac.ActionTeamsRead: []string{ac.ScopeTeamsAll}, - ac.ActionOrgUsersRead: []string{ac.ScopeUsersAll}, - serviceaccounts.ActionRead: []string{serviceaccounts.ScopeAll}, + ac.ActionTeamsRead: []string{ac.ScopeTeamsAll}, + ac.ActionOrgUsersRead: []string{ac.ScopeUsersAll}, }, }, } @@ -373,9 +372,21 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { require.EqualValues(t, getTeamQuery.Result.MemberCount, 2) }) - t.Run("Should be able to exclude service accounts from teamembers", func(t *testing.T) { + t.Run("Should be able to add service accounts to teams, list it as a team member and remove it from team", func(t *testing.T) { sqlStore = InitTestDB(t) setup() + + signedInUser := &models.SignedInUser{ + Login: "loginuser0", + OrgId: testOrgID, + Permissions: map[int64]map[string][]string{ + testOrgID: { + ac.ActionTeamsRead: []string{ac.ScopeTeamsAll}, + ac.ActionOrgUsersRead: []string{ac.ScopeUsersAll}, + }, + }, + } + userCmd = user.CreateUserCommand{ Email: fmt.Sprint("sa", 1, "@test.com"), Name: fmt.Sprint("sa", 1), @@ -385,24 +396,23 @@ func TestIntegrationTeamCommandsAndQueries(t *testing.T) { serviceAccount, err := sqlStore.CreateUser(context.Background(), userCmd) require.NoError(t, err) - groupId := team2.Id - // add service account to team - err = sqlStore.AddTeamMember(serviceAccount.ID, testOrgID, groupId, false, 0) + teamId := team1.Id + err = sqlStore.AddTeamMember(serviceAccount.ID, testOrgID, teamId, false, 0) require.NoError(t, err) - // add user to team - err = sqlStore.AddTeamMember(userIds[0], testOrgID, groupId, false, 0) + getTeamMembersQuery := &models.GetTeamMembersQuery{OrgId: testOrgID, TeamId: teamId, SignedInUser: signedInUser} + err = sqlStore.GetTeamMembers(context.Background(), getTeamMembersQuery) + require.NoError(t, err) + require.EqualValues(t, 1, len(getTeamMembersQuery.Result)) + require.EqualValues(t, serviceAccount.ID, getTeamMembersQuery.Result[0].UserId) + + removeTeamMemberCmd := &models.RemoveTeamMemberCommand{OrgId: testOrgID, TeamId: teamId, UserId: serviceAccount.ID} + err = sqlStore.RemoveTeamMember(context.Background(), removeTeamMemberCmd) require.NoError(t, err) - teamMembersQuery := &models.GetTeamMembersQuery{ - OrgId: testOrgID, - SignedInUser: testUser, - TeamId: groupId, - } - err = sqlStore.GetTeamMembers(context.Background(), teamMembersQuery) + err = sqlStore.GetTeamMembers(context.Background(), getTeamMembersQuery) require.NoError(t, err) - // should not receive service account from query - require.Equal(t, len(teamMembersQuery.Result), 1) + require.EqualValues(t, 0, len(getTeamMembersQuery.Result)) }) }) }) @@ -489,7 +499,7 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { t.Skip("skipping integration test") } testOrgID := int64(2) - userIds := make([]int64, 4) + userIds := make([]int64, 5) // Seed 2 teams with 2 members setup := func(store *SQLStore) { @@ -498,12 +508,15 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { team2, errCreateTeam := store.CreateTeam("group2 name", "test2@example.org", testOrgID) require.NoError(t, errCreateTeam) - for i := 0; i < 4; i++ { + for i := 0; i < 5; i++ { userCmd := user.CreateUserCommand{ Email: fmt.Sprint("user", i, "@example.org"), Name: fmt.Sprint("user", i), Login: fmt.Sprint("loginuser", i), } + if i >= 3 { + userCmd.IsServiceAccount = true + } user, errCreateUser := store.CreateUser(context.Background(), userCmd) require.NoError(t, errCreateUser) userIds[i] = user.ID @@ -517,6 +530,8 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { require.NoError(t, errAddMember) errAddMember = store.AddTeamMember(userIds[3], testOrgID, team2.Id, false, 0) require.NoError(t, errAddMember) + errAddMember = store.AddTeamMember(userIds[4], testOrgID, team2.Id, false, 0) + require.NoError(t, errAddMember) } store := InitTestDB(t, InitTestDBOpt{}) @@ -534,11 +549,14 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { query: &models.GetTeamMembersQuery{ OrgId: testOrgID, SignedInUser: &models.SignedInUser{ - OrgId: testOrgID, - Permissions: map[int64]map[string][]string{testOrgID: {ac.ActionOrgUsersRead: {ac.ScopeUsersAll}}}, + OrgId: testOrgID, + Permissions: map[int64]map[string][]string{testOrgID: { + ac.ActionOrgUsersRead: {ac.ScopeUsersAll}, + serviceaccounts.ActionRead: {serviceaccounts.ScopeAll}, + }}, }, }, - expectedNumUsers: 4, + expectedNumUsers: 5, }, { desc: "should return no team members", @@ -558,11 +576,15 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { OrgId: testOrgID, SignedInUser: &models.SignedInUser{ OrgId: testOrgID, - Permissions: map[int64]map[string][]string{testOrgID: {ac.ActionOrgUsersRead: { - ac.Scope("users", "id", fmt.Sprintf("%d", userIds[0])), - ac.Scope("users", "id", fmt.Sprintf("%d", userIds[2])), - ac.Scope("users", "id", fmt.Sprintf("%d", userIds[3])), - }}}, + Permissions: map[int64]map[string][]string{testOrgID: { + ac.ActionOrgUsersRead: { + ac.Scope("users", "id", fmt.Sprintf("%d", userIds[0])), + ac.Scope("users", "id", fmt.Sprintf("%d", userIds[2])), + }, + serviceaccounts.ActionRead: { + ac.Scope("serviceaccounts", "id", fmt.Sprintf("%d", userIds[3])), + }, + }}, }, }, expectedNumUsers: 3, @@ -576,10 +598,20 @@ func TestIntegrationSQLStore_GetTeamMembers_ACFilter(t *testing.T) { if !hasWildcardScope(tt.query.SignedInUser, ac.ActionOrgUsersRead) { for _, member := range tt.query.Result { - assert.Contains(t, - tt.query.SignedInUser.Permissions[tt.query.SignedInUser.OrgId][ac.ActionOrgUsersRead], - ac.Scope("users", "id", fmt.Sprintf("%d", member.UserId)), - ) + hasPermission := false + for _, scope := range tt.query.SignedInUser.Permissions[tt.query.SignedInUser.OrgId][ac.ActionOrgUsersRead] { + if scope == ac.Scope("users", "id", fmt.Sprintf("%d", member.UserId)) { + hasPermission = true + break + } + } + for _, scope := range tt.query.SignedInUser.Permissions[tt.query.SignedInUser.OrgId][serviceaccounts.ActionRead] { + if scope == ac.Scope("serviceaccounts", "id", fmt.Sprintf("%d", member.UserId)) { + hasPermission = true + break + } + } + assert.True(t, hasPermission) } } }) diff --git a/pkg/services/store/config.go b/pkg/services/store/config.go index cc65c201b69..3864397674d 100644 --- a/pkg/services/store/config.go +++ b/pkg/services/store/config.go @@ -1,9 +1,10 @@ package store type RootStorageConfig struct { - Type string `json:"type"` - Prefix string `json:"prefix"` - Name string `json:"name"` + Type string `json:"type"` + Prefix string `json:"prefix"` + Name string `json:"name"` + Description string `json:"description"` // Depending on type, these will be configured Disk *StorageLocalDiskConfig `json:"disk,omitempty"` diff --git a/pkg/services/store/http.go b/pkg/services/store/http.go index 413d5aa6fed..bdbb634a161 100644 --- a/pkg/services/store/http.go +++ b/pkg/services/store/http.go @@ -2,12 +2,14 @@ package store import ( "errors" + "fmt" "io/ioutil" "net/http" "strings" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" ) @@ -57,7 +59,8 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response { } c.Req.Body = http.MaxBytesReader(c.Resp, c.Req.Body, MAX_UPLOAD_SIZE) if err := c.Req.ParseMultipartForm(MAX_UPLOAD_SIZE); err != nil { - return response.Error(400, "Please limit file uploaded under 1MB", err) + msg := fmt.Sprintf("Please limit file uploaded under %s", util.ByteCountSI(MAX_UPLOAD_SIZE)) + return response.Error(400, msg, err) } files := c.Req.MultipartForm.File["file"] @@ -92,7 +95,7 @@ func (s *httpStorage) Upload(c *models.ReqContext) response.Response { return errFileTooBig } - path := RootUpload + "/" + fileHeader.Filename + path := RootResources + "/" + fileHeader.Filename mimeType := http.DetectContentType(data) diff --git a/pkg/services/store/service.go b/pkg/services/store/service.go index 8be2bddb2a8..be879f00133 100644 --- a/pkg/services/store/service.go +++ b/pkg/services/store/service.go @@ -6,7 +6,6 @@ import ( "fmt" "strings" - "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/infra/filestorage" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" @@ -26,15 +25,15 @@ var ErrValidationFailed = errors.New("request validation failed") var ErrFileAlreadyExists = errors.New("file exists") const RootPublicStatic = "public-static" -const RootUpload = "upload" +const RootResources = "resources" -const MAX_UPLOAD_SIZE = 1024 * 1024 // 1MB +const MAX_UPLOAD_SIZE = 3 * 1024 * 1024 // 3MB type StorageService interface { registry.BackgroundService // List folder contents - List(ctx context.Context, user *models.SignedInUser, path string) (*data.Frame, error) + List(ctx context.Context, user *models.SignedInUser, path string) (*StorageListFrame, error) // Read raw file contents out of the store Read(ctx context.Context, user *models.SignedInUser, path string) (*filestorage.File, error) @@ -60,21 +59,25 @@ func ProvideService(sql *sqlstore.SQLStore, features featuremgmt.FeatureToggles, Path: cfg.StaticRootPath, Roots: []string{ "/testdata/", - // "/img/icons/", - // "/img/bg/", "/img/", "/gazetteer/", "/maps/", }, - }).setReadOnly(true).setBuiltin(true), + }).setReadOnly(true).setBuiltin(true). + setDescription("Access files from the static public files"), } initializeOrgStorages := func(orgId int64) []storageRuntime { storages := make([]storageRuntime, 0) if features.IsEnabled(featuremgmt.FlagStorageLocalUpload) { - config := &StorageSQLConfig{orgId: orgId} - storages = append(storages, newSQLStorage(RootUpload, "Local file upload", config, sql).setBuiltin(true)) + storages = append(storages, + newSQLStorage(RootResources, + "Resources", + &StorageSQLConfig{orgId: orgId}, sql). + setBuiltin(true). + setDescription("Upload custom resource files")) } + return storages } @@ -110,7 +113,7 @@ func getOrgId(user *models.SignedInUser) int64 { return user.OrgId } -func (s *standardStorageService) List(ctx context.Context, user *models.SignedInUser, path string) (*data.Frame, error) { +func (s *standardStorageService) List(ctx context.Context, user *models.SignedInUser, path string) (*StorageListFrame, error) { // apply access control here return s.tree.ListFolder(ctx, getOrgId(user), path) } @@ -133,16 +136,16 @@ type UploadRequest struct { } func (s *standardStorageService) Upload(ctx context.Context, user *models.SignedInUser, req *UploadRequest) error { - upload, _ := s.tree.getRoot(getOrgId(user), RootUpload) + upload, _ := s.tree.getRoot(getOrgId(user), RootResources) if upload == nil { return ErrUploadFeatureDisabled } - if !strings.HasPrefix(req.Path, RootUpload+"/") { + if !strings.HasPrefix(req.Path, RootResources+"/") { return ErrUnsupportedStorage } - storagePath := strings.TrimPrefix(req.Path, RootUpload) + storagePath := strings.TrimPrefix(req.Path, RootResources) validationResult := s.validateUploadRequest(ctx, user, req, storagePath) if !validationResult.ok { grafanaStorageLogger.Warn("file upload validation failed", "filetype", req.MimeType, "path", req.Path, "reason", validationResult.reason) @@ -178,7 +181,7 @@ func (s *standardStorageService) Upload(ctx context.Context, user *models.Signed } func (s *standardStorageService) Delete(ctx context.Context, user *models.SignedInUser, path string) error { - upload, _ := s.tree.getRoot(getOrgId(user), RootUpload) + upload, _ := s.tree.getRoot(getOrgId(user), RootResources) if upload == nil { return fmt.Errorf("upload feature is not enabled") } diff --git a/pkg/services/store/service_test.go b/pkg/services/store/service_test.go index 073f135cb3b..9dba7282639 100644 --- a/pkg/services/store/service_test.go +++ b/pkg/services/store/service_test.go @@ -43,15 +43,15 @@ func TestListFiles(t *testing.T) { frame, err := store.List(context.Background(), dummyUser, "public/testdata") require.NoError(t, err) - experimental.CheckGoldenJSONFrame(t, "testdata", "public_testdata.golden", frame, true) + experimental.CheckGoldenJSONFrame(t, "testdata", "public_testdata.golden", frame.Frame, true) file, err := store.Read(context.Background(), dummyUser, "public/testdata/js_libraries.csv") require.NoError(t, err) require.NotNil(t, file) - frame, err = testdatasource.LoadCsvContent(bytes.NewReader(file.Contents), file.Name) + testDsFrame, err := testdatasource.LoadCsvContent(bytes.NewReader(file.Contents), file.Name) require.NoError(t, err) - experimental.CheckGoldenJSONFrame(t, "testdata", "public_testdata_js_libraries.golden", frame, true) + experimental.CheckGoldenJSONFrame(t, "testdata", "public_testdata_js_libraries.golden", testDsFrame, true) } func TestUpload(t *testing.T) { @@ -63,7 +63,7 @@ func TestUpload(t *testing.T) { request := UploadRequest{ EntityType: EntityTypeImage, Contents: make([]byte, 0), - Path: "upload/myFile.jpg", + Path: "resources/myFile.jpg", MimeType: "image/jpg", } err = s.Upload(context.Background(), dummyUser, &request) diff --git a/pkg/services/store/testdata/example_list_frame.json b/pkg/services/store/testdata/example_list_frame.json new file mode 100644 index 00000000000..92814399aa8 --- /dev/null +++ b/pkg/services/store/testdata/example_list_frame.json @@ -0,0 +1,73 @@ +{ + "schema": { + "meta": { + "type": "directory-listing", + "custom": { + "HasMore": false + } + }, + "fields": [ + { + "name": "name", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "mediaType", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "size", + "type": "number", + "typeInfo": { + "frame": "int64" + }, + "config": { + "unit": "bytes" + } + } + ] + }, + "data": { + "values": [ + [ + "DL_1.jpg", + "Screen Shot 2022-06-23 at 9.05.39 PM.png", + "Screen Shot 2022-06-24 at 11.58.32 AM.png", + "Screen Shot 2022-06-30 at 3.45.03 PM.png", + "Screen Shot 2022-07-05 at 3.24.27 PM.png", + "image.png", + "rocket_1f680.png", + "test-folder", + "topcoder12.png" + ], + [ + "image/jpeg", + "image/png", + "image/png", + "image/png", + "image/png", + "image/png", + "image/png", + "directory", + "image/png" + ], + [ + 943004, + 684257, + 256396, + 8796, + 388290, + 182568, + 29066, + 0, + 90563 + ] + ] + } +} diff --git a/pkg/services/store/testdata/example_root_level_list.json b/pkg/services/store/testdata/example_root_level_list.json new file mode 100644 index 00000000000..1846b25b29e --- /dev/null +++ b/pkg/services/store/testdata/example_root_level_list.json @@ -0,0 +1,90 @@ +{ + "schema": { + "meta": { + "type": "directory-listing" + }, + "fields": [ + { + "name": "name", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "title", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "description", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "mediaType", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "storageType", + "type": "string", + "typeInfo": { + "frame": "string" + } + }, + { + "name": "readOnly", + "type": "boolean", + "typeInfo": { + "frame": "bool" + } + }, + { + "name": "builtIn", + "type": "boolean", + "typeInfo": { + "frame": "bool" + } + } + ] + }, + "data": { + "values": [ + [ + "public-static", + "resources" + ], + [ + "Public static files", + "Resources" + ], + [ + "Access files from the static public files", + "Upload custom resource files" + ], + [ + "directory", + "directory" + ], + [ + "disk", + "sql" + ], + [ + true, + false + ], + [ + true, + true + ] + ] + } +} diff --git a/pkg/services/store/tree.go b/pkg/services/store/tree.go index 47ebf5ab9e5..24f32377dbd 100644 --- a/pkg/services/store/tree.go +++ b/pkg/services/store/tree.go @@ -85,39 +85,60 @@ func (t *nestedTree) GetFile(ctx context.Context, orgId int64, path string) (*fi return root.Get(ctx, path) } -func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string) (*data.Frame, error) { +func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string) (*StorageListFrame, error) { if path == "" || path == "/" { t.assureOrgIsInitialized(orgId) + idx := 0 count := len(t.rootsByOrgId[ac.GlobalOrgID]) if orgId != ac.GlobalOrgID { count += len(t.rootsByOrgId[orgId]) } - title := data.NewFieldFromFieldType(data.FieldTypeString, count) names := data.NewFieldFromFieldType(data.FieldTypeString, count) + title := data.NewFieldFromFieldType(data.FieldTypeString, count) + descr := data.NewFieldFromFieldType(data.FieldTypeString, count) + types := data.NewFieldFromFieldType(data.FieldTypeString, count) + readOnly := data.NewFieldFromFieldType(data.FieldTypeBool, count) + builtIn := data.NewFieldFromFieldType(data.FieldTypeBool, count) mtype := data.NewFieldFromFieldType(data.FieldTypeString, count) - title.Name = "title" - names.Name = "name" - mtype.Name = "mediaType" - for i, f := range t.rootsByOrgId[ac.GlobalOrgID] { - names.Set(i, f.Meta().Config.Prefix) - title.Set(i, f.Meta().Config.Name) - mtype.Set(i, "directory") + title.Name = titleListFrameField + names.Name = nameListFrameField + descr.Name = descriptionListFrameField + mtype.Name = mediaTypeListFrameField + types.Name = storageTypeListFrameField + readOnly.Name = readOnlyListFrameField + builtIn.Name = builtInListFrameField + for _, f := range t.rootsByOrgId[ac.GlobalOrgID] { + meta := f.Meta() + names.Set(idx, meta.Config.Prefix) + title.Set(idx, meta.Config.Name) + descr.Set(idx, meta.Config.Description) + mtype.Set(idx, "directory") + types.Set(idx, meta.Config.Type) + readOnly.Set(idx, meta.ReadOnly) + builtIn.Set(idx, meta.Builtin) + idx++ } if orgId != ac.GlobalOrgID { - for i, f := range t.rootsByOrgId[orgId] { - names.Set(i, f.Meta().Config.Prefix) - title.Set(i, f.Meta().Config.Name) - mtype.Set(i, "directory") + for _, f := range t.rootsByOrgId[orgId] { + meta := f.Meta() + names.Set(idx, meta.Config.Prefix) + title.Set(idx, meta.Config.Name) + descr.Set(idx, meta.Config.Description) + mtype.Set(idx, "directory") + types.Set(idx, meta.Config.Type) + readOnly.Set(idx, meta.ReadOnly) + builtIn.Set(idx, meta.Builtin) + idx++ } } - frame := data.NewFrame("", names, title, mtype) + frame := data.NewFrame("", names, title, descr, mtype, types, readOnly, builtIn) frame.SetMeta(&data.FrameMeta{ Type: data.FrameTypeDirectoryListing, }) - return frame, nil + return &StorageListFrame{frame}, nil } root, path := t.getRoot(orgId, path) @@ -125,7 +146,11 @@ func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string) ( return nil, nil // not found (or not ready) } - listResponse, err := root.List(ctx, path, nil, &filestorage.ListOptions{Recursive: false, WithFolders: true, WithFiles: true}) + listResponse, err := root.List(ctx, path, nil, &filestorage.ListOptions{ + Recursive: false, + WithFolders: true, + WithFiles: true, + }) if err != nil { return nil, err @@ -135,9 +160,9 @@ func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string) ( names := data.NewFieldFromFieldType(data.FieldTypeString, count) mtype := data.NewFieldFromFieldType(data.FieldTypeString, count) fsize := data.NewFieldFromFieldType(data.FieldTypeInt64, count) - names.Name = "name" - mtype.Name = "mediaType" - fsize.Name = "size" + names.Name = nameListFrameField + mtype.Name = mediaTypeListFrameField + fsize.Name = sizeListFrameField fsize.Config = &data.FieldConfig{ Unit: "bytes", } @@ -153,5 +178,5 @@ func (t *nestedTree) ListFolder(ctx context.Context, orgId int64, path string) ( "HasMore": listResponse.HasMore, }, }) - return frame, nil + return &StorageListFrame{frame}, nil } diff --git a/pkg/services/store/types.go b/pkg/services/store/types.go index 6acd3ad7e65..db9cdfa8202 100644 --- a/pkg/services/store/types.go +++ b/pkg/services/store/types.go @@ -30,7 +30,7 @@ type WriteValueResponse struct { type storageTree interface { GetFile(ctx context.Context, orgId int64, path string) (*filestorage.File, error) - ListFolder(ctx context.Context, orgId int64, path string) (*data.Frame, error) + ListFolder(ctx context.Context, orgId int64, path string) (*StorageListFrame, error) } //------------------------------------------- @@ -82,6 +82,11 @@ func (t *baseStorageRuntime) setBuiltin(val bool) *baseStorageRuntime { return t } +func (t *baseStorageRuntime) setDescription(v string) *baseStorageRuntime { + t.meta.Config.Description = v + return t +} + type RootStorageMeta struct { ReadOnly bool `json:"editable,omitempty"` Builtin bool `json:"builtin,omitempty"` @@ -90,3 +95,38 @@ type RootStorageMeta struct { Config RootStorageConfig `json:"config"` } + +type StorageListFrame struct { + *data.Frame +} + +const ( + titleListFrameField = "title" + nameListFrameField = "name" + descriptionListFrameField = "description" + mediaTypeListFrameField = "mediaType" + storageTypeListFrameField = "storageType" + readOnlyListFrameField = "readOnly" + builtInListFrameField = "builtIn" + sizeListFrameField = "size" +) + +func (s *StorageListFrame) GetFileNames() []string { + var fileNames []string + if s == nil { + return fileNames + } + + field, idx := s.FieldByName(nameListFrameField) + if field.Len() == 0 || idx == -1 { + return fileNames + } + + for i := 0; i < field.Len(); i++ { + if stringValue, ok := field.At(i).(string); ok { + fileNames = append(fileNames, stringValue) + } + } + + return fileNames +} diff --git a/pkg/services/store/types_test.go b/pkg/services/store/types_test.go new file mode 100644 index 00000000000..3374c04aed8 --- /dev/null +++ b/pkg/services/store/types_test.go @@ -0,0 +1,47 @@ +package store + +import ( + _ "embed" + "testing" + + "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/stretchr/testify/require" +) + +var ( + //go:embed testdata/example_list_frame.json + exampleListFrameJSON string + + //go:embed testdata/example_root_level_list.json + exampleRootLevelListJSON string +) + +func TestGetFileNames(t *testing.T) { + frame := &data.Frame{} + err := frame.UnmarshalJSON([]byte(exampleListFrameJSON)) + require.NoError(t, err) + + listFrame := StorageListFrame{frame} + require.Equal(t, []string{ + "DL_1.jpg", + "Screen Shot 2022-06-23 at 9.05.39 PM.png", + "Screen Shot 2022-06-24 at 11.58.32 AM.png", + "Screen Shot 2022-06-30 at 3.45.03 PM.png", + "Screen Shot 2022-07-05 at 3.24.27 PM.png", + "image.png", "rocket_1f680.png", + "test-folder", + "topcoder12.png", + }, listFrame.GetFileNames()) +} + +func TestGetFileNamesRootLevel(t *testing.T) { + frame := &data.Frame{} + err := frame.UnmarshalJSON([]byte(exampleRootLevelListJSON)) + require.NoError(t, err) + + listFrame := StorageListFrame{frame} + require.Equal(t, []string{ + "public-static", + "resources", + }, listFrame.GetFileNames()) +} diff --git a/pkg/tests/api/alerting/api_provisioning_test.go b/pkg/tests/api/alerting/api_provisioning_test.go index fb1a02df474..f4d78dde729 100644 --- a/pkg/tests/api/alerting/api_provisioning_test.go +++ b/pkg/tests/api/alerting/api_provisioning_test.go @@ -44,13 +44,32 @@ func TestProvisioning(t *testing.T) { url := fmt.Sprintf("http://%s/api/v1/provisioning/policies", grafanaListedAddr) body := ` { - "receiver": "grafana-default-email", + "receiver": "test-receiver", "group_by": [ "..." ], "routes": [] }` + // As we check if the receiver exists that is referenced in the policy, + // we first need to create it, so the tests passes correctly. + urlReceiver := fmt.Sprintf("http://%s/api/v1/provisioning/contact-points", grafanaListedAddr) + bodyReceiver := ` + { + "name": "test-receiver", + "type": "slack", + "settings": { + "recipient": "value_recipient", + "token": "value_token" + } + }` + + req := createTestRequest("POST", urlReceiver, "admin", bodyReceiver) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + require.Equal(t, 202, resp.StatusCode) + t.Run("un-authenticated GET should 401", func(t *testing.T) { req := createTestRequest("GET", url, "", "") @@ -127,7 +146,6 @@ func TestProvisioning(t *testing.T) { resp, err := http.DefaultClient.Do(req) require.NoError(t, err) require.NoError(t, resp.Body.Close()) - require.Equal(t, 202, resp.StatusCode) }) }) diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 1213bf8bba5..cae8ee96d75 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -267,6 +267,12 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) { _, err = anonSect.NewKey("plugin_admin_enabled", "true") require.NoError(t, err) } + if o.PluginAdminExternalManageEnabled { + anonSect, err := cfg.NewSection("plugins") + require.NoError(t, err) + _, err = anonSect.NewKey("plugin_admin_external_manage_enabled", "true") + require.NoError(t, err) + } if o.ViewersCanEdit { usersSection, err := cfg.NewSection("users") require.NoError(t, err) @@ -322,6 +328,7 @@ type GrafanaOpts struct { CatalogAppEnabled bool ViewersCanEdit bool PluginAdminEnabled bool + PluginAdminExternalManageEnabled bool AppModeProduction bool DisableLegacyAlerting bool EnableUnifiedAlerting bool diff --git a/pkg/tsdb/grafanads/grafana.go b/pkg/tsdb/grafanads/grafana.go index 129f3ea875d..a1011f39962 100644 --- a/pkg/tsdb/grafanads/grafana.go +++ b/pkg/tsdb/grafanads/grafana.go @@ -109,10 +109,10 @@ func (s *Service) doListQuery(ctx context.Context, query backend.DataQuery) back } path := store.RootPublicStatic + "/" + q.Path - frame, err := s.store.List(ctx, nil, path) + listFrame, err := s.store.List(ctx, nil, path) response.Error = err - if frame != nil { - response.Frames = data.Frames{frame} + if listFrame != nil { + response.Frames = data.Frames{listFrame.Frame} } return response } diff --git a/pkg/util/strings.go b/pkg/util/strings.go index e066ca5c2bf..1cacce669ef 100644 --- a/pkg/util/strings.go +++ b/pkg/util/strings.go @@ -117,3 +117,17 @@ func Capitalize(s string) string { r[0] = unicode.ToUpper(r[0]) return string(r) } + +func ByteCountSI(b int64) string { + const unit = 1000 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cB", + float64(b)/float64(div), "kMGTPE"[exp]) +} diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx index 2b8efc69b88..14ade91ddf7 100644 --- a/public/app/AppWrapper.tsx +++ b/public/app/AppWrapper.tsx @@ -12,6 +12,7 @@ import { store } from 'app/store/store'; import { AngularRoot } from './angular/AngularRoot'; import { loadAndInitAngularIfEnabled } from './angular/loadAndInitAngularIfEnabled'; import { GrafanaApp } from './app'; +import { AppChrome } from './core/components/AppChrome/AppChrome'; import { AppNotificationList } from './core/components/AppNotifications/AppNotificationList'; import { NavBar } from './core/components/NavBar/NavBar'; import { I18nProvider } from './core/localisation'; @@ -125,7 +126,7 @@ export class AppWrapper extends React.Component {this.renderNavBar()} -
    + {pageBanners.map((Banner, index) => ( ))} @@ -137,7 +138,7 @@ export class AppWrapper extends React.Component ( ))} -
    +
    diff --git a/public/app/angular/angular_wrappers.ts b/public/app/angular/angular_wrappers.ts index 15159553cd7..30a63a5af02 100644 --- a/public/app/angular/angular_wrappers.ts +++ b/public/app/angular/angular_wrappers.ts @@ -17,7 +17,7 @@ import { QueryEditor as CloudMonitoringQueryEditor } from 'app/plugins/datasourc import EmptyListCTA from '../core/components/EmptyListCTA/EmptyListCTA'; import { Footer } from '../core/components/Footer/Footer'; -import PageHeader from '../core/components/PageHeader/PageHeader'; +import { PageHeader } from '../core/components/PageHeader/PageHeader'; import { MetricSelect } from '../core/components/Select/MetricSelect'; import { TagFilter } from '../core/components/TagFilter/TagFilter'; import { HelpModal } from '../core/components/help/HelpModal'; diff --git a/public/app/angular/services/nav_model_srv.ts b/public/app/angular/services/nav_model_srv.ts index 73506625130..bf8790e1b3b 100644 --- a/public/app/angular/services/nav_model_srv.ts +++ b/public/app/angular/services/nav_model_srv.ts @@ -71,7 +71,6 @@ export function getWarningNav(text: string, subTitle?: string): NavModel { icon: 'exclamation-triangle', }; return { - breadcrumbs: [node], node: node, main: node, }; diff --git a/public/app/core/components/AccessControl/AddPermission.tsx b/public/app/core/components/AccessControl/AddPermission.tsx index 8c17862ec7a..9785b75776a 100644 --- a/public/app/core/components/AccessControl/AddPermission.tsx +++ b/public/app/core/components/AccessControl/AddPermission.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Button, Form, HorizontalGroup, Select } 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'; import { UserPicker } from 'app/core/components/Select/UserPicker'; import { OrgRole } from 'app/types/acl'; @@ -28,6 +29,9 @@ export const AddPermission = ({ title = 'Add Permission For', permissions, assig if (assignments.users) { options.push({ value: PermissionTarget.User, label: 'User' }); } + if (assignments.serviceAccounts) { + options.push({ value: PermissionTarget.ServiceAccount, label: 'Service Account' }); + } if (assignments.teams) { options.push({ value: PermissionTarget.Team, label: 'Team' }); } @@ -46,6 +50,7 @@ export const AddPermission = ({ title = 'Add Permission For', permissions, assig const isValid = () => (target === PermissionTarget.Team && teamId > 0) || (target === PermissionTarget.User && userId > 0) || + (target === PermissionTarget.ServiceAccount && userId > 0) || (PermissionTarget.BuiltInRole && OrgRole.hasOwnProperty(builtInRole)); return ( @@ -72,6 +77,10 @@ export const AddPermission = ({ title = 'Add Permission For', permissions, assig setUserId(u.value || 0)} className={'width-20'} /> )} + {target === PermissionTarget.ServiceAccount && ( + setUserId(s.value?.id || 0)} className={'width-20'} /> + )} + {target === PermissionTarget.Team && ( setTeamId(t.value?.id || 0)} className={'width-20'} /> )} diff --git a/public/app/core/components/AccessControl/Permissions.tsx b/public/app/core/components/AccessControl/Permissions.tsx index 39446dedd47..0e8b9955ef1 100644 --- a/public/app/core/components/AccessControl/Permissions.tsx +++ b/public/app/core/components/AccessControl/Permissions.tsx @@ -16,6 +16,7 @@ const INITIAL_DESCRIPTION: Description = { assignments: { teams: false, users: false, + serviceAccounts: false, builtInRoles: false, }, }; @@ -57,7 +58,7 @@ export const Permissions = ({ const onAdd = (state: SetPermission) => { let promise: Promise | null = null; - if (state.target === PermissionTarget.User) { + if (state.target === PermissionTarget.User || state.target === PermissionTarget.ServiceAccount) { promise = setUserPermission(resource, resourceId, state.userId!, state.permission); } else if (state.target === PermissionTarget.Team) { promise = setTeamPermission(resource, resourceId, state.teamId!, state.permission); @@ -109,7 +110,15 @@ export const Permissions = ({ const users = useMemo( () => sortBy( - items.filter((i) => i.userId), + items.filter((i) => i.userId && !i.userIsServiceAccount), + ['userLogin'] + ), + [items] + ); + const serviceAccounts = useMemo( + () => + sortBy( + items.filter((i) => i.userId && i.userIsServiceAccount), ['userLogin'] ), [items] @@ -161,6 +170,14 @@ export const Permissions = ({ onRemove={onRemove} canSet={canSetPermissions} /> + { - /** This is nav tree id provided by route. - * It's not enough for item navigation. For that pages will need provide an item nav model as well via TopNavUpdate - */ - navId?: string; -} +export interface Props extends PropsWithChildren<{}> {} -export function TopNavPage({ children, navId }: Props) { +export function AppChrome({ children }: Props) { const styles = useStyles2(getStyles); const [searchBarHidden, toggleSearchBar] = useToggle(false); // repace with local storage - const props = useObservable(topNavUpdates, topNavDefaultProps); - const navModel = useSelector(createSelector(getNavIndex, (navIndex) => getNavModel(navIndex, navId ?? 'home'))); + const [megaMenuOpen, setMegaMenuOpen] = useState(false); + const state = appChromeService.useState(); + + if (state.chromeless || !config.featureToggles.topnav) { + return
    {children}
    ; + } return ( -
    +
    {!searchBarHidden && } setMegaMenuOpen(!megaMenuOpen)} />
    {children}
    -
    + {megaMenuOpen && setMegaMenuOpen(false)} />} + ); } -function getNavIndex(store: StoreState) { - return store.navIndex; -} - 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 0.6px 1.5px rgb(0 0 0 / 8%), 0 2px 4px rgb(0 0 0 / 6%), 0 5px 10px rgb(0 0 0 / 5%)'; return { - viewport: css({ + content: css({ display: 'flex', + flexDirection: 'column', + paddingTop: TOP_BAR_LEVEL_HEIGHT * 2, flexGrow: 1, height: '100%', }), - content: css({ - display: 'flex', - paddingTop: TOP_BAR_LEVEL_HEIGHT * 2 + 16, - flexGrow: 1, - }), contentNoSearchBar: css({ - paddingTop: TOP_BAR_LEVEL_HEIGHT + 16, + paddingTop: TOP_BAR_LEVEL_HEIGHT, }), topNav: css({ display: 'flex', diff --git a/public/app/core/components/AppChrome/AppChromeService.tsx b/public/app/core/components/AppChrome/AppChromeService.tsx new file mode 100644 index 00000000000..0c113f7701d --- /dev/null +++ b/public/app/core/components/AppChrome/AppChromeService.tsx @@ -0,0 +1,51 @@ +import { useObservable } from 'react-use'; +import { BehaviorSubject } from 'rxjs'; + +import { NavModelItem } from '@grafana/data'; +import { isShallowEqual } from 'app/core/utils/isShallowEqual'; + +import { RouteDescriptor } from '../../navigation/types'; + +export interface AppChromeState { + chromeless: boolean; + sectionNav: NavModelItem; + pageNav?: NavModelItem; + actions?: React.ReactNode; +} + +const defaultSection: NavModelItem = { text: 'Grafana' }; + +export class AppChromeService { + readonly state = new BehaviorSubject({ + chromeless: true, // start out hidden to not flash it on pages without chrome + sectionNav: defaultSection, + }); + + routeMounted(route: RouteDescriptor) { + this.update({ + chromeless: route.chromeless === true, + sectionNav: defaultSection, + pageNav: undefined, + actions: undefined, + }); + } + + update(state: Partial) { + const current = this.state.getValue(); + const newState: AppChromeState = { + ...current, + ...state, + }; + + if (!isShallowEqual(current, newState)) { + this.state.next(newState); + } + } + + useState() { + // eslint-disable-next-line react-hooks/rules-of-hooks + return useObservable(this.state, this.state.getValue()); + } +} + +export const appChromeService = new AppChromeService(); diff --git a/public/app/core/components/AppChrome/AppChromeUpdate.tsx b/public/app/core/components/AppChrome/AppChromeUpdate.tsx new file mode 100644 index 00000000000..38545bfce8f --- /dev/null +++ b/public/app/core/components/AppChrome/AppChromeUpdate.tsx @@ -0,0 +1,22 @@ +import React, { useEffect } from 'react'; + +import { NavModelItem } from '@grafana/data'; + +import { appChromeService } from './AppChromeService'; + +export interface AppChromeUpdateProps { + pageNav?: NavModelItem; + actions?: React.ReactNode; +} +/** + * This needs to be moved to @grafana/ui or runtime. + * This is the way core pages and plugins update the breadcrumbs and page toolbar actions + */ +export const AppChromeUpdate = React.memo(({ pageNav, actions }: AppChromeUpdateProps) => { + useEffect(() => { + appChromeService.update({ pageNav, actions }); + }); + return null; +}); + +AppChromeUpdate.displayName = 'TopNavUpdate'; diff --git a/public/app/core/components/TopNav/Breadcrumbs.tsx b/public/app/core/components/AppChrome/Breadcrumbs.tsx similarity index 86% rename from public/app/core/components/TopNav/Breadcrumbs.tsx rename to public/app/core/components/AppChrome/Breadcrumbs.tsx index bdfad00315e..c7bc80ec1d8 100644 --- a/public/app/core/components/TopNav/Breadcrumbs.tsx +++ b/public/app/core/components/AppChrome/Breadcrumbs.tsx @@ -4,11 +4,9 @@ import React from 'react'; import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { useStyles2, Icon, IconName } from '@grafana/ui'; -import { TopNavProps } from './TopNavUpdate'; - -export interface Props extends TopNavProps { +export interface Props { sectionNav: NavModelItem; - subNav?: NavModelItem; + pageNav?: NavModelItem; } export interface Breadcrumb { @@ -17,9 +15,9 @@ export interface Breadcrumb { href?: string; } -export function Breadcrumbs({ sectionNav, subNav }: Props) { +export function Breadcrumbs({ sectionNav, pageNav }: Props) { const styles = useStyles2(getStyles); - const crumbs: Breadcrumb[] = [{ icon: 'home', href: '/' }]; + const crumbs: Breadcrumb[] = [{ icon: 'home-alt', href: '/' }]; function addCrumbs(node: NavModelItem) { if (node.parentItem) { @@ -31,8 +29,8 @@ export function Breadcrumbs({ sectionNav, subNav }: Props) { addCrumbs(sectionNav); - if (subNav) { - addCrumbs(subNav); + if (pageNav) { + addCrumbs(pageNav); } return ( diff --git a/public/app/core/components/TopNav/NavToolbar.tsx b/public/app/core/components/AppChrome/NavToolbar.tsx similarity index 80% rename from public/app/core/components/TopNav/NavToolbar.tsx rename to public/app/core/components/AppChrome/NavToolbar.tsx index afe8474d3d1..d0ca6b346d6 100644 --- a/public/app/core/components/TopNav/NavToolbar.tsx +++ b/public/app/core/components/AppChrome/NavToolbar.tsx @@ -5,25 +5,33 @@ import { GrafanaTheme2, NavModelItem } from '@grafana/data'; import { IconButton, ToolbarButton, useStyles2 } from '@grafana/ui'; import { Breadcrumbs } from './Breadcrumbs'; -import { TopNavProps } from './TopNavUpdate'; import { TOP_BAR_LEVEL_HEIGHT } from './types'; -export interface Props extends TopNavProps { +export interface Props { onToggleSearchBar(): void; + onToggleMegaMenu(): void; searchBarHidden?: boolean; sectionNav: NavModelItem; - subNav?: NavModelItem; + pageNav?: NavModelItem; + actions: React.ReactNode; } -export function NavToolbar({ actions, onToggleSearchBar, searchBarHidden, sectionNav, subNav }: Props) { +export function NavToolbar({ + actions, + searchBarHidden, + sectionNav, + pageNav, + onToggleMegaMenu, + onToggleSearchBar, +}: Props) { const styles = useStyles2(getStyles); return (
    - {}} /> +
    - +
    {actions} diff --git a/public/app/core/components/TopNav/TopSearchBar.tsx b/public/app/core/components/AppChrome/TopSearchBar.tsx similarity index 100% rename from public/app/core/components/TopNav/TopSearchBar.tsx rename to public/app/core/components/AppChrome/TopSearchBar.tsx diff --git a/public/app/core/components/AppChrome/types.ts b/public/app/core/components/AppChrome/types.ts new file mode 100644 index 00000000000..654334fdea6 --- /dev/null +++ b/public/app/core/components/AppChrome/types.ts @@ -0,0 +1,8 @@ +import { NavModelItem } from '@grafana/data'; + +export const TOP_BAR_LEVEL_HEIGHT = 40; + +export interface ToolbarUpdateProps { + pageNav?: NavModelItem; + actions?: React.ReactNode; +} diff --git a/public/app/core/components/ErrorPage/ErrorPage.tsx b/public/app/core/components/ErrorPage/ErrorPage.tsx index 8fe3e4c645e..25e77912f6c 100644 --- a/public/app/core/components/ErrorPage/ErrorPage.tsx +++ b/public/app/core/components/ErrorPage/ErrorPage.tsx @@ -7,7 +7,7 @@ import { Icon } from '@grafana/ui'; import { getNavModel } from 'app/core/selectors/navModel'; import { StoreState } from 'app/types'; -import Page from '../Page/Page'; +import { Page } from '../Page/Page'; interface ConnectedProps { navModel: NavModel; diff --git a/public/app/core/components/MegaMenu/MegaMenu.test.tsx b/public/app/core/components/MegaMenu/MegaMenu.test.tsx new file mode 100644 index 00000000000..04283424de5 --- /dev/null +++ b/public/app/core/components/MegaMenu/MegaMenu.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { Router } from 'react-router-dom'; + +import { NavModelItem, NavSection } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { configureStore } from 'app/store/configureStore'; + +import TestProvider from '../../../../test/helpers/TestProvider'; + +import { MegaMenu } from './MegaMenu'; + +const setup = () => { + const navBarTree: NavModelItem[] = [ + { + text: 'Section name', + section: NavSection.Core, + id: 'section', + url: 'section', + children: [ + { text: 'Child1', id: 'child1', url: 'section/child1' }, + { text: 'Child2', id: 'child2', url: 'section/child2' }, + ], + }, + { + text: 'Profile', + id: 'profile', + section: NavSection.Config, + url: 'profile', + }, + ]; + + const store = configureStore({ navBarTree }); + + return render( + + + + {}} /> + + + + ); +}; + +describe('MegaMenu', () => { + it('should render component', async () => { + setup(); + + expect(await screen.findByTestId('navbarmenu')).toBeInTheDocument(); + expect(await screen.findByLabelText('Home')).toBeInTheDocument(); + expect(screen.queryAllByLabelText('Section name').length).toBe(2); + }); + + it('should filter out profile', async () => { + setup(); + + expect(screen.queryByLabelText('Profile')).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/core/components/MegaMenu/MegaMenu.tsx b/public/app/core/components/MegaMenu/MegaMenu.tsx new file mode 100644 index 00000000000..894949df2c4 --- /dev/null +++ b/public/app/core/components/MegaMenu/MegaMenu.tsx @@ -0,0 +1,80 @@ +import { css } from '@emotion/css'; +import { cloneDeep } from 'lodash'; +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useLocation } from 'react-router-dom'; + +import { GrafanaTheme2, NavModelItem, NavSection } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { useTheme2 } from '@grafana/ui'; +import { StoreState } from 'app/types'; + +import { enrichConfigItems, enrichWithInteractionTracking, getActiveItem } from '../NavBar/utils'; + +import { NavBarMenu } from './NavBarMenu'; + +export interface Props { + onClose: () => void; + searchBarHidden?: boolean; +} + +export const MegaMenu = React.memo(({ onClose, searchBarHidden }) => { + const navBarTree = useSelector((state: StoreState) => state.navBarTree); + const theme = useTheme2(); + const styles = getStyles(theme); + const location = useLocation(); + const [showSwitcherModal, setShowSwitcherModal] = useState(false); + + const toggleSwitcherModal = () => { + setShowSwitcherModal(!showSwitcherModal); + }; + + const homeItem: NavModelItem = enrichWithInteractionTracking( + { + id: 'home', + text: 'Home', + url: config.appSubUrl || '/', + icon: 'home-alt', + }, + true + ); + + const navTree = cloneDeep(navBarTree); + + const coreItems = navTree + .filter((item) => item.section === NavSection.Core) + .map((item) => enrichWithInteractionTracking(item, true)); + const pluginItems = navTree + .filter((item) => item.section === NavSection.Plugin) + .map((item) => enrichWithInteractionTracking(item, true)); + const configItems = enrichConfigItems( + navTree.filter((item) => item.section === NavSection.Config && item && item.id !== 'help' && item.id !== 'profile'), + location, + toggleSwitcherModal + ).map((item) => enrichWithInteractionTracking(item, true)); + + const activeItem = getActiveItem(navTree, location.pathname); + + return ( +
    + +
    + ); +}); + +MegaMenu.displayName = 'MegaMenu'; + +const getStyles = (theme: GrafanaTheme2) => ({ + menuWrapper: css({ + position: 'fixed', + display: 'grid', + gridAutoFlow: 'column', + height: '100%', + zIndex: theme.zIndex.sidemenu, + }), +}); diff --git a/public/app/core/components/MegaMenu/NavBarMenu.tsx b/public/app/core/components/MegaMenu/NavBarMenu.tsx new file mode 100644 index 00000000000..2b2df363de4 --- /dev/null +++ b/public/app/core/components/MegaMenu/NavBarMenu.tsx @@ -0,0 +1,211 @@ +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, { useRef } from 'react'; +import CSSTransition from 'react-transition-group/CSSTransition'; + +import { GrafanaTheme2, NavModelItem } from '@grafana/data'; +import { reportInteraction } from '@grafana/runtime'; +import { CustomScrollbar, Icon, IconButton, useTheme2 } from '@grafana/ui'; + +import { TOP_BAR_LEVEL_HEIGHT } from '../AppChrome/types'; +import { NavItem } from '../NavBar/NavBarMenu'; +import { NavBarToggle } from '../NavBar/NavBarToggle'; + +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 ref = useRef(null); + const { dialogProps } = useDialog({}, ref); + + const { overlayProps, underlayProps } = useOverlay( + { + isDismissable: true, + isOpen: true, + onClose, + }, + ref + ); + + return ( + + + +
    +
    + + +
    + { + reportInteraction('grafana_navigation_collapsed'); + onClose(); + }} + /> + +
    +
    + +
    + + + + ); +} + +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: topPosition, + zIndex: theme.zIndex.navbarFixed - 2, + }), + container: css({ + display: 'flex', + bottom: 0, + flexDirection: 'column', + left: 0, + paddingTop: theme.spacing(1), + marginRight: theme.spacing(1.5), + right: 0, + // Needs to below navbar should we change the navbarFixed? add add a new level? + zIndex: theme.zIndex.navbarFixed - 1, + position: 'fixed', + top: topPosition, + boxSizing: 'content-box', + [theme.breakpoints.up('md')]: { + borderRight: `1px solid ${theme.colors.border.weak}`, + right: 'unset', + }, + }), + content: css({ + display: 'flex', + flexDirection: 'column', + overflow: 'auto', + }), + mobileHeader: css({ + borderBottom: `1px solid ${theme.colors.border.weak}`, + display: 'flex', + justifyContent: 'space-between', + padding: theme.spacing(1, 2, 2), + [theme.breakpoints.up('md')]: { + display: 'none', + }, + }), + itemList: css({ + display: 'grid', + gridAutoRows: `minmax(${theme.spacing(6)}, auto)`, + minWidth: MENU_WIDTH, + }), + menuCollapseIcon: css({ + position: 'absolute', + top: '43px', + right: '0px', + transform: `translateX(50%)`, + }), + }; +}; + +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: 'background-color, 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 = { + backgroundColor: theme.colors.background.primary, + boxShadow: theme.shadows.z3, + width: '100%', + [theme.breakpoints.up('md')]: { + width: MENU_WIDTH, + }, + }; + + const overlayClosed = { + boxShadow: 'none', + width: 0, + [theme.breakpoints.up('md')]: { + backgroundColor: theme.colors.background.primary, + width: theme.spacing(7), + }, + }; + + const backdropOpen = { + opacity: 1, + }; + + const backdropClosed = { + opacity: 0, + }; + + return { + backdrop: { + appear: css(backdropClosed), + appearActive: css(backdropTransition, backdropOpen), + appearDone: css(backdropOpen), + exit: css(backdropOpen), + exitActive: css(backdropTransition, backdropClosed), + }, + overlay: { + appear: css(overlayClosed), + appearActive: css(overlayTransition, overlayOpen), + appearDone: css(overlayOpen), + exit: css(overlayOpen), + exitActive: css(overlayTransition, overlayClosed), + }, + }; +}; diff --git a/public/app/core/components/NavBar/NavBarMenu.tsx b/public/app/core/components/NavBar/NavBarMenu.tsx index e549cd222b7..53ac531b05a 100644 --- a/public/app/core/components/NavBar/NavBarMenu.tsx +++ b/public/app/core/components/NavBar/NavBarMenu.tsx @@ -220,7 +220,7 @@ const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => { }; }; -function NavItem({ +export function NavItem({ link, activeItem, onClose, @@ -458,7 +458,7 @@ function linkHasChildren(link: NavModelItem): link is NavModelItem & { children: } function getLinkIcon(link: NavModelItem) { - if (link.id === 'home') { + if (link.icon === 'grafana') { return ; } else if (link.icon) { return ; diff --git a/public/app/core/components/Page/Page.test.tsx b/public/app/core/components/Page/Page.test.tsx new file mode 100644 index 00000000000..3ac2e7edcff --- /dev/null +++ b/public/app/core/components/Page/Page.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; + +import { NavModelItem } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { configureStore } from 'app/store/configureStore'; + +import { Page } from './Page'; +import { PageProps } from './types'; + +const pageNav: NavModelItem = { + text: 'Main title', + children: [ + { text: 'Child1', url: '1', active: true }, + { text: 'Child2', url: '2' }, + ], +}; + +const setup = (props: Partial) => { + config.bootData.navTree = [ + { + text: 'Section name', + id: 'section', + url: 'section', + children: [ + { text: 'Child1', id: 'child1', url: 'section/child1' }, + { text: 'Child2', id: 'child2', url: 'section/child2' }, + ], + }, + ]; + + const store = configureStore(); + + return render( + + +
    Children
    +
    +
    + ); +}; + +describe('Render', () => { + it('should render component with emtpy Page container', async () => { + setup({}); + const children = await screen.findByTestId('page-children'); + expect(children).toBeInTheDocument(); + + const pageHeader = screen.queryByRole('heading'); + expect(pageHeader).not.toBeInTheDocument(); + }); + + it('should render header when pageNav supplied', async () => { + setup({ pageNav }); + + expect(screen.getByRole('heading', { name: 'Main title' })).toBeInTheDocument(); + expect(screen.getAllByRole('tab').length).toBe(2); + }); + + it('should get header nav model from redux navIndex', async () => { + setup({ navId: 'child1' }); + + expect(screen.getByRole('heading', { name: 'Section name' })).toBeInTheDocument(); + expect(screen.getAllByRole('tab').length).toBe(2); + }); +}); diff --git a/public/app/core/components/Page/Page.tsx b/public/app/core/components/Page/Page.tsx index 121b9ba1548..643c9ee281e 100644 --- a/public/app/core/components/Page/Page.tsx +++ b/public/app/core/components/Page/Page.tsx @@ -1,45 +1,33 @@ // Libraries import { css, cx } from '@emotion/css'; -import React, { FC, HTMLAttributes, useEffect } from 'react'; +import React from 'react'; -import { GrafanaTheme2, NavModel } from '@grafana/data'; +import { GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { CustomScrollbar, useStyles2 } from '@grafana/ui'; -import { getTitleFromNavModel } from 'app/core/selectors/navModel'; -// Components -import { Branding } from '../Branding/Branding'; import { Footer } from '../Footer/Footer'; -import PageHeader from '../PageHeader/PageHeader'; +import { PageHeader } from '../PageHeader/PageHeader'; +import { Page as NewPage } from '../PageNew/Page'; import { PageContents } from './PageContents'; +import { PageType } from './types'; +import { usePageNav } from './usePageNav'; +import { usePageTitle } from './usePageTitle'; -interface Props extends HTMLAttributes { - children: React.ReactNode; - navModel?: NavModel; -} - -export interface PageType extends FC { - Header: typeof PageHeader; - Contents: typeof PageContents; -} - -export const Page: PageType = ({ navModel, children, className, ...otherProps }) => { +export const OldPage: PageType = ({ navId, navModel: oldNavProp, pageNav, children, className, ...otherProps }) => { const styles = useStyles2(getStyles); + const navModel = usePageNav(navId, oldNavProp); - useEffect(() => { - if (navModel) { - const title = getTitleFromNavModel(navModel); - document.title = title ? `${title} - ${Branding.AppTitle}` : Branding.AppTitle; - } else { - document.title = Branding.AppTitle; - } - }, [navModel]); + usePageTitle(navModel, pageNav); + + const pageHeaderNav = pageNav ?? navModel?.main; return (
    - {navModel && } + {pageHeaderNav && } {children}
    @@ -48,12 +36,12 @@ export const Page: PageType = ({ navModel, children, className, ...otherProps }) ); }; -Page.Header = PageHeader; -Page.Contents = PageContents; +OldPage.Header = PageHeader; +OldPage.Contents = PageContents; -export default Page; +export const Page: PageType = config.featureToggles.topnav ? NewPage : OldPage; -const getStyles = (theme: GrafanaTheme2) => ({ +const getStyles = (_: GrafanaTheme2) => ({ wrapper: css` width: 100%; flex-grow: 1; diff --git a/public/app/core/components/Page/types.ts b/public/app/core/components/Page/types.ts new file mode 100644 index 00000000000..bc8340f22fc --- /dev/null +++ b/public/app/core/components/Page/types.ts @@ -0,0 +1,19 @@ +import { FC, HTMLAttributes } from 'react'; + +import { NavModel, NavModelItem } from '@grafana/data'; + +import { PageHeader } from '../PageHeader/PageHeader'; + +import { PageContents } from './PageContents'; + +export interface PageProps extends HTMLAttributes { + children: React.ReactNode; + navId?: string; + navModel?: NavModel; + pageNav?: NavModelItem; +} + +export interface PageType extends FC { + Header: typeof PageHeader; + Contents: typeof PageContents; +} diff --git a/public/app/core/components/Page/usePageNav.ts b/public/app/core/components/Page/usePageNav.ts new file mode 100644 index 00000000000..e78a5d17aa6 --- /dev/null +++ b/public/app/core/components/Page/usePageNav.ts @@ -0,0 +1,29 @@ +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { NavModel } from '@grafana/data'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { store } from 'app/store/store'; +import { StoreState } from 'app/types'; + +export function usePageNav(navId?: string, oldProp?: NavModel): NavModel | undefined { + if (oldProp) { + return oldProp; + } + + if (!navId) { + return; + } + + // Page component is used in so many tests, this simplifies not having to initialize a full redux store + if (!store) { + return; + } + + // eslint-disable-next-line react-hooks/rules-of-hooks + return useSelector(createSelector(getNavIndex, (navIndex) => getNavModel(navIndex, navId ?? 'home'))); +} + +function getNavIndex(store: StoreState) { + return store.navIndex; +} diff --git a/public/app/core/components/Page/usePageTitle.ts b/public/app/core/components/Page/usePageTitle.ts new file mode 100644 index 00000000000..b7512bb7b46 --- /dev/null +++ b/public/app/core/components/Page/usePageTitle.ts @@ -0,0 +1,32 @@ +import { useEffect } from 'react'; + +import { NavModel, NavModelItem } from '@grafana/data'; + +import { Branding } from '../Branding/Branding'; + +export function usePageTitle(navModel?: NavModel, pageNav?: NavModelItem) { + useEffect(() => { + const parts: string[] = []; + + if (pageNav) { + if (pageNav.children) { + const activePage = pageNav.children.find((x) => x.active); + if (activePage) { + parts.push(activePage.text); + } + } + parts.push(pageNav.text); + } + + if (navModel) { + if (navModel.node !== navModel.main) { + parts.push(navModel.node.text); + } + parts.push(navModel.main.text); + } + + parts.push(Branding.AppTitle); + + document.title = parts.join(' - '); + }, [navModel, pageNav]); +} diff --git a/public/app/core/components/PageHeader/PageHeader.test.tsx b/public/app/core/components/PageHeader/PageHeader.test.tsx index 2965d007142..3e5af5ee1f9 100644 --- a/public/app/core/components/PageHeader/PageHeader.test.tsx +++ b/public/app/core/components/PageHeader/PageHeader.test.tsx @@ -1,23 +1,20 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; -import PageHeader from './PageHeader'; +import { PageHeader } from './PageHeader'; describe('PageHeader', () => { describe('when the nav tree has a node with a title', () => { it('should render the title', async () => { const nav = { - main: { - icon: 'folder-open', - id: 'node', - subTitle: 'node subtitle', - url: '', - text: 'node', - }, - node: {}, + icon: 'folder-open', + id: 'node', + subTitle: 'node subtitle', + url: '', + text: 'node', }; - render(); + render(); expect(screen.getByRole('heading', { name: 'node' })).toBeInTheDocument(); }); @@ -26,18 +23,15 @@ describe('PageHeader', () => { describe('when the nav tree has a node with breadcrumbs and a title', () => { it('should render the title with breadcrumbs first and then title last', async () => { const nav = { - main: { - icon: 'folder-open', - id: 'child', - subTitle: 'child subtitle', - url: '', - text: 'child', - breadcrumbs: [{ title: 'Parent', url: 'parentUrl' }], - }, - node: {}, + icon: 'folder-open', + id: 'child', + subTitle: 'child subtitle', + url: '', + text: 'child', + breadcrumbs: [{ title: 'Parent', url: 'parentUrl' }], }; - render(); + render(); expect(screen.getByRole('heading', { name: 'Parent / child' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'Parent' })).toBeInTheDocument(); diff --git a/public/app/core/components/PageHeader/PageHeader.tsx b/public/app/core/components/PageHeader/PageHeader.tsx index 116ff8186dd..45fe58f1474 100644 --- a/public/app/core/components/PageHeader/PageHeader.tsx +++ b/public/app/core/components/PageHeader/PageHeader.tsx @@ -1,14 +1,14 @@ import { css } from '@emotion/css'; import React, { FC } from 'react'; -import { NavModel, NavModelItem, NavModelBreadcrumb, GrafanaTheme2 } from '@grafana/data'; +import { NavModelItem, NavModelBreadcrumb, GrafanaTheme2 } from '@grafana/data'; import { Tab, TabsBar, Icon, IconName, useStyles2 } from '@grafana/ui'; import { PanelHeaderMenuItem } from 'app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuItem'; import { ProBadge } from '../Upgrade/ProBadge'; export interface Props { - model: NavModel; + navItem: NavModelItem; } const SelectNav = ({ children, customCss }: { children: NavModelItem[]; customCss: string }) => { @@ -75,21 +75,19 @@ const Navigation = ({ children }: { children: NavModelItem[] }) => { ); }; -export const PageHeader: FC = ({ model }) => { +export const PageHeader: FC = ({ navItem: model }) => { const styles = useStyles2(getStyles); if (!model) { return null; } - const main = model.main; - const children = main.children; return (
    - {renderHeaderTitle(main)} - {children && children.length && {children}} + {renderHeaderTitle(model)} + {model.children && model.children.length > 0 && {model.children}}
    @@ -157,5 +155,3 @@ const getStyles = (theme: GrafanaTheme2) => ({ background: ${theme.colors.background.canvas}; `, }); - -export default PageHeader; diff --git a/public/app/core/components/PageNew/Page.test.tsx b/public/app/core/components/PageNew/Page.test.tsx new file mode 100644 index 00000000000..a28ecec2b7d --- /dev/null +++ b/public/app/core/components/PageNew/Page.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; + +import { NavModelItem } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { configureStore } from 'app/store/configureStore'; + +import { PageProps } from '../Page/types'; + +import { Page } from './Page'; + +const pageNav: NavModelItem = { + text: 'pageNav title', + children: [ + { text: 'pageNav child1', url: '1', active: true }, + { text: 'pageNav child2', url: '2' }, + ], +}; + +const setup = (props: Partial) => { + config.bootData.navTree = [ + { + text: 'Section name', + id: 'section', + url: 'section', + children: [ + { text: 'Child1', id: 'child1', url: 'section/child1' }, + { text: 'Child2', id: 'child2', url: 'section/child2' }, + ], + }, + ]; + + const store = configureStore(); + + return render( + + +
    Children
    +
    +
    + ); +}; + +describe('Render', () => { + it('should render component with emtpy Page container', async () => { + setup({}); + const children = await screen.findByTestId('page-children'); + expect(children).toBeInTheDocument(); + + const pageHeader = screen.queryByRole('heading'); + expect(pageHeader).not.toBeInTheDocument(); + }); + + it('should render header when pageNav supplied', async () => { + setup({ pageNav }); + + expect(screen.getByRole('heading', { name: 'pageNav title' })).toBeInTheDocument(); + expect(screen.getAllByRole('tab').length).toBe(2); + }); + + it('should render section nav model based on navId', async () => { + setup({ navId: 'child1' }); + + expect(screen.getByRole('heading', { name: 'Section name' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Child1' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Tab Child1' })).toBeInTheDocument(); + expect(screen.getAllByRole('tab').length).toBe(2); + }); + + it('should render section nav model based on navId and item page nav', async () => { + setup({ navId: 'child1', pageNav }); + + expect(screen.getByRole('heading', { name: '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(); + }); +}); diff --git a/public/app/core/components/PageNew/Page.tsx b/public/app/core/components/PageNew/Page.tsx new file mode 100644 index 00000000000..72bf56d951a --- /dev/null +++ b/public/app/core/components/PageNew/Page.tsx @@ -0,0 +1,93 @@ +// Libraries +import { css, cx } from '@emotion/css'; +import React, { useEffect } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { CustomScrollbar, useStyles2 } from '@grafana/ui'; + +// Components +import { appChromeService } from '../AppChrome/AppChromeService'; +import { Footer } from '../Footer/Footer'; +import { PageType } from '../Page/types'; +import { usePageNav } from '../Page/usePageNav'; +import { usePageTitle } from '../Page/usePageTitle'; + +import { PageContents } from './PageContents'; +import { PageHeader } from './PageHeader'; +import { PageTabs } from './PageTabs'; +import { SectionNav } from './SectionNav'; + +export const Page: PageType = ({ navId, navModel: oldNavProp, pageNav, children, className, ...otherProps }) => { + const styles = useStyles2(getStyles); + const navModel = usePageNav(navId, oldNavProp); + + usePageTitle(navModel, pageNav); + + const pageHeaderNav = pageNav ?? navModel?.node; + + useEffect(() => { + if (navModel || pageNav) { + appChromeService.update({ sectionNav: navModel?.node, pageNav }); + } + }, [navModel, pageNav]); + + return ( +
    +
    + {navModel && navModel.main.children && } +
    + +
    + {pageHeaderNav && } + {pageNav && pageNav.children && } + {children} +
    +
    + +
    +
    +
    + ); +}; + +Page.Header = PageHeader; +Page.Contents = PageContents; + +const getStyles = (theme: GrafanaTheme2) => { + const shadow = theme.isDark + ? `0 0.6px 1.5px -1px rgb(0 0 0),0 2px 4px -1px rgb(0 0 0 / 40%),0 5px 10px -1px rgb(0 0 0 / 23%)` + : '0 0.6px 1.5px -1px rgb(0 0 0 / 8%),0 2px 4px rgb(0 0 0 / 6%),0 5px 10px -1px rgb(0 0 0 / 5%)'; + + return { + wrapper: css` + height: 100%; + display: flex; + flex: 1 1 0; + flex-direction: column; + min-height: 0; + `, + panes: css({ + display: 'flex', + height: '100%', + width: '100%', + flexGrow: 1, + minHeight: 0, + flexDirection: 'column', + [theme.breakpoints.up('md')]: { + flexDirection: 'row', + }, + }), + pageContent: css({ + flexGrow: 1, + }), + pageInner: css({ + padding: theme.spacing(3), + boxShadow: shadow, + background: theme.colors.background.primary, + margin: theme.spacing(2, 2, 2, 1), + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + }), + }; +}; diff --git a/public/app/core/components/PageNew/PageContents.tsx b/public/app/core/components/PageNew/PageContents.tsx new file mode 100644 index 00000000000..85a04fa295a --- /dev/null +++ b/public/app/core/components/PageNew/PageContents.tsx @@ -0,0 +1,14 @@ +// Libraries +import React, { FC } from 'react'; + +import PageLoader from '../PageLoader/PageLoader'; + +interface Props { + isLoading?: boolean; + children: React.ReactNode; + className?: string; +} + +export const PageContents: FC = ({ isLoading, children }) => { + return <>{isLoading ? : children}; +}; diff --git a/public/app/core/components/PageNew/PageHeader.tsx b/public/app/core/components/PageNew/PageHeader.tsx new file mode 100644 index 00000000000..1879563c90e --- /dev/null +++ b/public/app/core/components/PageNew/PageHeader.tsx @@ -0,0 +1,43 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { NavModelItem, GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +export interface Props { + navItem: NavModelItem; +} + +export function PageHeader({ navItem }: Props) { + const styles = useStyles2(getStyles); + + return ( + <> +

    + {navItem.img && {`logo} + {navItem.text} +

    + {navItem.subTitle &&
    {navItem.subTitle}
    } + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + pageTitle: css({ + display: 'flex', + marginBottom: theme.spacing(3), + }), + pageSubTitle: css({ + marginBottom: theme.spacing(2), + position: 'relative', + top: theme.spacing(-1), + color: theme.colors.text.secondary, + }), + pageImg: css({ + width: '32px', + height: '32px', + marginRight: theme.spacing(2), + }), + }; +}; diff --git a/public/app/core/components/PageNew/PageTabs.tsx b/public/app/core/components/PageNew/PageTabs.tsx new file mode 100644 index 00000000000..d3fb3a73c9b --- /dev/null +++ b/public/app/core/components/PageNew/PageTabs.tsx @@ -0,0 +1,42 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { NavModelItem, GrafanaTheme2 } from '@grafana/data'; +import { IconName, useStyles2, TabsBar, Tab } from '@grafana/ui'; + +export interface Props { + navItem: NavModelItem; +} + +export function PageTabs({ navItem }: Props) { + const styles = useStyles2(getStyles); + + return ( +
    + + {navItem.children!.map((child, index) => { + return ( + !child.hideFromTabs && ( + + ) + ); + })} + +
    + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + tabsWrapper: css({ + paddingBottom: theme.spacing(3), + }), + }; +}; diff --git a/public/app/core/components/PageNew/SectionNav.tsx b/public/app/core/components/PageNew/SectionNav.tsx new file mode 100644 index 00000000000..3570de4294f --- /dev/null +++ b/public/app/core/components/PageNew/SectionNav.tsx @@ -0,0 +1,92 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { NavModel, GrafanaTheme2 } from '@grafana/data'; +import { IconName, useStyles2, Icon, VerticalTab } from '@grafana/ui'; + +export interface Props { + model: NavModel; +} + +export function SectionNav(props: Props) { + const styles = useStyles2(getStyles); + + const main = props.model.main; + const directChildren = props.model.main.children!.filter((x) => !x.hideFromTabs && !x.children); + const nestedItems = props.model.main.children!.filter((x) => x.children && x.children.length); + + return ( + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + nav: css({ + display: 'flex', + flexDirection: 'column', + background: theme.colors.background.canvas, + padding: theme.spacing(3, 2), + flexShrink: 0, + [theme.breakpoints.up('md')]: { + width: '250px', + }, + }), + sectionName: css({ + display: 'flex', + gap: theme.spacing(1), + padding: theme.spacing(0.5, 0, 3, 0.25), + fontSize: theme.typography.h4.fontSize, + margin: 0, + }), + items: css({ + // paddingLeft: '9px', + }), + subSection: css({ + padding: theme.spacing(3, 0, 1, 1), + fontWeight: 500, + fontSize: '16px', + }), + }; +}; diff --git a/public/app/core/components/QueryOperationRow/QueryOperationRow.test.tsx b/public/app/core/components/QueryOperationRow/QueryOperationRow.test.tsx index b046539d99f..2efdd6a2729 100644 --- a/public/app/core/components/QueryOperationRow/QueryOperationRow.test.tsx +++ b/public/app/core/components/QueryOperationRow/QueryOperationRow.test.tsx @@ -1,56 +1,44 @@ -import { mount, shallow } from 'enzyme'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { QueryOperationRow } from './QueryOperationRow'; +import { QueryOperationRow, QueryOperationRowProps } from './QueryOperationRow'; + +const setup = (propOverrides?: Partial) => { + const props: QueryOperationRowProps = { + title: 'test-title', + headerElement: '', + index: 0, + id: 'test-id', + children:
    children
    , + ...propOverrides, + }; + return render(); +}; describe('QueryOperationRow', () => { - it('renders', () => { - expect(() => - shallow( - -
    Test
    -
    - ) - ).not.toThrow(); + it('renders without exploding', () => { + expect(() => setup()).not.toThrow(); + }); + + it('renders the component content', () => { + setup(); + expect(screen.getByText(/^test-title$/)).toBeInTheDocument(); }); describe('callbacks', () => { - it('should not call onOpen when component is shallowed', async () => { - const onOpenSpy = jest.fn(); - // @ts-ignore strict null error, you shouldn't use promise like approach with act but I don't know what the intention is here - await act(async () => { - shallow( - -
    Test
    -
    - ); - }); - expect(onOpenSpy).not.toBeCalled(); - }); - it('should call onOpen when row is opened and onClose when row is collapsed', async () => { const onOpenSpy = jest.fn(); const onCloseSpy = jest.fn(); - const wrapper = mount( - -
    Test
    -
    - ); - const titleEl = wrapper.find({ 'aria-label': 'Query operation row title' }); - expect(titleEl).toHaveLength(1); + setup({ isOpen: false, onOpen: onOpenSpy, onClose: onCloseSpy }); - // @ts-ignore strict null error, you shouldn't use promise like approach with act but I don't know what the intention is here - await act(async () => { - // open - titleEl.first().simulate('click'); - }); + const queryRow = screen.getByText(/^test-title$/); + expect(queryRow).toBeInTheDocument(); - // @ts-ignore strict null error, you shouldn't use promise like approach with act but I don't know what the intention is here - await act(async () => { - // close - titleEl.first().simulate('click'); - }); + // open row on click + await userEvent.click(queryRow); + // close row on click + await userEvent.click(queryRow); expect(onOpenSpy).toBeCalledTimes(1); expect(onCloseSpy).toBeCalledTimes(1); @@ -59,40 +47,26 @@ describe('QueryOperationRow', () => { describe('headerElement rendering', () => { it('should render headerElement provided as element', () => { - const title =
    Test
    ; - const wrapper = mount( - -
    Test
    -
    - ); + const title =
    test-header-element
    ; + setup({ headerElement: title, id: 'test-id', index: 0 }); - const titleEl = wrapper.find({ 'aria-label': 'test title' }); - expect(titleEl).toHaveLength(1); + expect(screen.getByText(/^test-header-element$/)).toBeInTheDocument(); }); it('should render headerElement provided as function', () => { - const title = () =>
    Test
    ; - const wrapper = mount( - -
    Test
    -
    - ); + const title = () =>
    test-function-header
    ; + setup({ headerElement: title, id: 'test-id', index: 0 }); - const titleEl = wrapper.find({ 'aria-label': 'test title' }); - expect(titleEl).toHaveLength(1); + expect(screen.getByText(/^test-function-header$/)).toBeInTheDocument(); }); it('should expose api to headerElement rendered as function', () => { const propsSpy = jest.fn(); - const title = (props: any) => { + const title = (props: Partial) => { propsSpy(props); return
    Test
    ; }; - shallow( - -
    Test
    -
    - ); + setup({ headerElement: title, id: 'test-id', index: 0 }); expect(Object.keys(propsSpy.mock.calls[0][0])).toContain('isOpen'); }); @@ -100,40 +74,27 @@ describe('QueryOperationRow', () => { describe('actions rendering', () => { it('should render actions provided as element', () => { - const actions =
    Test
    ; - const wrapper = mount( - -
    Test
    -
    - ); + const actions =
    test-actions
    ; + setup({ actions: actions, id: 'test-id', index: 0 }); - const actionsEl = wrapper.find({ 'aria-label': 'test actions' }); - expect(actionsEl).toHaveLength(1); + expect(screen.getByText(/^test-actions$/)).toBeInTheDocument(); }); it('should render actions provided as function', () => { - const actions = () =>
    Test
    ; - const wrapper = mount( - -
    Test
    -
    - ); + const actions = () =>
    test-actions
    ; + setup({ actions: actions, id: 'test-id', index: 0 }); - const actionsEl = wrapper.find({ 'aria-label': 'test actions' }); - expect(actionsEl).toHaveLength(1); + expect(screen.getByText(/^test-actions$/)).toBeInTheDocument(); }); it('should expose api to title rendered as function', () => { const propsSpy = jest.fn(); - const actions = (props: any) => { + const actions = (props: Partial) => { propsSpy(props); - return
    Test
    ; + return
    test-actions
    ; }; - shallow( - -
    Test
    -
    - ); + setup({ actions: actions, id: 'test-id', index: 0 }); + expect(screen.getByText(/^test-actions$/)).toBeInTheDocument(); expect(Object.keys(propsSpy.mock.calls[0][0])).toEqual(['isOpen', 'onOpen', 'onClose']); }); }); diff --git a/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx b/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx index 331e189e976..5fd86d4267d 100644 --- a/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx +++ b/public/app/core/components/QueryOperationRow/QueryOperationRow.tsx @@ -9,7 +9,7 @@ import { ReactUtils, stylesFactory, useTheme } from '@grafana/ui'; import { QueryOperationRowHeader } from './QueryOperationRowHeader'; -interface QueryOperationRowProps { +export interface QueryOperationRowProps { index: number; id: string; title?: string; diff --git a/public/app/core/components/Select/ServiceAccountPicker.tsx b/public/app/core/components/Select/ServiceAccountPicker.tsx new file mode 100644 index 00000000000..954f1ddc425 --- /dev/null +++ b/public/app/core/components/Select/ServiceAccountPicker.tsx @@ -0,0 +1,69 @@ +import { debounce, isNil } from 'lodash'; +import React, { useEffect, useState } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { getBackendSrv } from '@grafana/runtime'; +import { AsyncSelect } from '@grafana/ui'; +import { ServiceAccount } from 'app/types'; + +export interface Props { + onSelected: (serviceAccount: SelectableValue) => void; + className?: string; + inputId?: string; + autoFocus?: boolean; +} + +export function ServiceAccountPicker({ onSelected, className, inputId, autoFocus }: Props) { + const [loadingServiceAccounts, setLoadingServiceAccounts] = useState(false); + + // For whatever reason the autoFocus prop doesn't seem to work + // with AsyncSelect, hence this workaround. Maybe fixed in a later version? + useEffect(() => { + if (autoFocus && inputId) { + document.getElementById(inputId)?.focus(); + } + }, [autoFocus, inputId]); + + let search = (query?: string) => { + setLoadingServiceAccounts(true); + + if (isNil(query)) { + query = ''; + } + + return getBackendSrv() + .get(`/api/serviceaccounts/search?query=${query}`) + .then((result: { serviceAccounts: ServiceAccount[] }) => { + const serviceAccounts: Array> = result.serviceAccounts.map((serviceAccount) => { + return { + id: serviceAccount.id, + value: serviceAccount, + label: serviceAccount.login, + imgUrl: serviceAccount.avatarUrl, + login: serviceAccount.login, + }; + }); + setLoadingServiceAccounts(false); + return serviceAccounts; + }); + }; + + const debouncedSearch = debounce(search, 300, { + leading: true, + trailing: true, + }); + + return ( + + ); +} diff --git a/public/app/core/components/TopNav/TopNavUpdate.tsx b/public/app/core/components/TopNav/TopNavUpdate.tsx deleted file mode 100644 index c16ba7a2755..00000000000 --- a/public/app/core/components/TopNav/TopNavUpdate.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useEffect } from 'react'; -import { Subject } from 'rxjs'; - -import { NavModelItem } from '@grafana/data'; - -export interface TopNavProps { - subNav?: NavModelItem; - actions?: React.ReactNode; -} - -export const topNavUpdates = new Subject(); -export const topNavDefaultProps: TopNavProps = {}; - -/** - * This needs to be moved to @grafana/ui or runtime. - * This is the way core pages and plugins update the breadcrumbs and page toolbar actions - */ -export function TopNavUpdate(props: TopNavProps) { - useEffect(() => { - topNavUpdates.next(props); - }); - return null; -} diff --git a/public/app/core/components/TopNav/types.ts b/public/app/core/components/TopNav/types.ts deleted file mode 100644 index 9171a881c48..00000000000 --- a/public/app/core/components/TopNav/types.ts +++ /dev/null @@ -1 +0,0 @@ -export const TOP_BAR_LEVEL_HEIGHT = 40; diff --git a/public/app/core/logsModel.ts b/public/app/core/logsModel.ts index 48facc8143f..eae01687679 100644 --- a/public/app/core/logsModel.ts +++ b/public/app/core/logsModel.ts @@ -1,5 +1,5 @@ import { size } from 'lodash'; -import { Observable } from 'rxjs'; +import { Observable, from, isObservable } from 'rxjs'; import { AbsoluteTimeRange, @@ -8,6 +8,7 @@ import { DataQueryRequest, DataQueryResponse, DataSourceApi, + DataSourceJsonData, dateTimeFormat, dateTimeFormatTimeAgo, FieldCache, @@ -106,13 +107,20 @@ export function filterLogLevels(logRows: LogRowModel[], hiddenLogLevels: Set; + target: LogLevel; + color: string; +} + export function makeDataFramesForLogs(sortedRows: LogRowModel[], bucketSize: number): DataFrame[] { // currently interval is rangeMs / resolution, which is too low for showing series as bars. // Should be solved higher up the chain when executing queries & interval calculated and not here but this is a temporary fix. // Graph time series by log level - const seriesByLevel: any = {}; - const seriesList: any[] = []; + const seriesByLevel: Record = {}; + const seriesList: Series[] = []; for (const row of sortedRows) { let series = seriesByLevel[row.logLevel]; @@ -459,12 +467,13 @@ export function logSeriesToLogsModel(logSeries: DataFrame[]): LogsModel | undefi } const limits = logSeries.filter((series) => series.meta && series.meta.limit); - const limitValue = Object.values( - limits.reduce((acc: any, elem: any) => { - acc[elem.refId] = elem.meta.limit; - return acc; - }, {}) - ).reduce((acc: number, elem: any) => (acc += elem), 0) as number; + const lastLimitPerRef = limits.reduce>((acc, elem) => { + acc[elem.refId ?? ''] = elem.meta?.limit ?? 0; + + return acc; + }, {}); + + const limitValue = Object.values(lastLimitPerRef).reduce((acc, elem) => (acc += elem), 0); if (limitValue > 0) { meta.push({ @@ -598,6 +607,7 @@ export function aggregateRawLogsVolume( extractLevel: (dataFrame: DataFrame) => LogLevel ): DataFrame[] { const logsVolumeByLevelMap: Partial> = {}; + rawLogsVolume.forEach((dataFrame) => { const level = extractLevel(dataFrame); if (!logsVolumeByLevelMap[level]) { @@ -661,10 +671,10 @@ type LogsVolumeQueryOptions = { /** * Creates an observable, which makes requests to get logs volume and aggregates results. */ -export function queryLogsVolume( - datasource: DataSourceApi, - logsVolumeRequest: DataQueryRequest, - options: LogsVolumeQueryOptions +export function queryLogsVolume( + datasource: DataSourceApi, + logsVolumeRequest: DataQueryRequest, + options: LogsVolumeQueryOptions ): Observable { const timespan = options.range.to.valueOf() - options.range.from.valueOf(); const intervalInfo = getIntervalInfo(logsVolumeRequest.scopedVars, timespan); @@ -683,7 +693,10 @@ export function queryLogsVolume( data: [], }); - const subscription = (datasource.query(logsVolumeRequest) as Observable).subscribe({ + const queryResponse = datasource.query(logsVolumeRequest); + const queryObservable = isObservable(queryResponse) ? queryResponse : from(queryResponse); + + const subscription = queryObservable.subscribe({ complete: () => { const aggregatedLogsVolume = aggregateRawLogsVolume(rawLogsVolume, options.extractLevel); if (aggregatedLogsVolume[0]) { diff --git a/public/app/core/navigation/GrafanaRoute.tsx b/public/app/core/navigation/GrafanaRoute.tsx index 7b2e7445b0d..17a2e93d3b9 100644 --- a/public/app/core/navigation/GrafanaRoute.tsx +++ b/public/app/core/navigation/GrafanaRoute.tsx @@ -2,9 +2,9 @@ import React from 'react'; // @ts-ignore import Drop from 'tether-drop'; -import { config, locationSearchToObject, navigationLogger, reportPageview } from '@grafana/runtime'; +import { locationSearchToObject, navigationLogger, reportPageview } from '@grafana/runtime'; -import { TopNavPage } from '../components/TopNav/TopNavPage'; +import { appChromeService } from '../components/AppChrome/AppChromeService'; import { keybindingSrv } from '../services/keybindingSrv'; import { GrafanaRouteComponentProps } from './types'; @@ -13,6 +13,8 @@ export interface Props extends Omit { export class GrafanaRoute extends React.Component { componentDidMount() { + appChromeService.routeMounted(this.props.route); + this.updateBodyClassNames(); this.cleanupDOM(); // unbinds all and re-bind global keybindins @@ -71,12 +73,6 @@ export class GrafanaRoute extends React.Component { navigationLogger('GrafanaRoute', false, 'Rendered', props.route); const RouteComponent = props.route.component; - const routeElement = ; - - if (config.featureToggles.topnav && !props.route.navHidden) { - return {routeElement}; - } - - return routeElement; + return ; } } diff --git a/public/app/core/navigation/types.ts b/public/app/core/navigation/types.ts index d34089aca8f..cffda8ede63 100644 --- a/public/app/core/navigation/types.ts +++ b/public/app/core/navigation/types.ts @@ -17,7 +17,6 @@ export interface RouteDescriptor { pageClass?: string; /** Can be used like an id for the route if the same component is used by many routes */ routeName?: string; - navHidden?: boolean; + chromeless?: boolean; exact?: boolean; - navId?: string; } diff --git a/public/app/core/reducers/navBarTree.ts b/public/app/core/reducers/navBarTree.ts index b05979ac042..6907d7af6b9 100644 --- a/public/app/core/reducers/navBarTree.ts +++ b/public/app/core/reducers/navBarTree.ts @@ -1,7 +1,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { NavModelItem } from '@grafana/data'; -import config from 'app/core/config'; +import { config } from '@grafana/runtime'; export const initialState: NavModelItem[] = config.bootData?.navTree ?? []; diff --git a/public/app/core/reducers/navModel.ts b/public/app/core/reducers/navModel.ts index bdb256bfe1a..6be4673248d 100644 --- a/public/app/core/reducers/navModel.ts +++ b/public/app/core/reducers/navModel.ts @@ -32,7 +32,6 @@ function buildWarningNav(text: string, subTitle?: string): NavModel { icon: 'exclamation-triangle', }; return { - breadcrumbs: [node], node: node, main: node, }; diff --git a/public/app/core/utils/isShallowEqual.ts b/public/app/core/utils/isShallowEqual.ts new file mode 100644 index 00000000000..8d772c8af5b --- /dev/null +++ b/public/app/core/utils/isShallowEqual.ts @@ -0,0 +1,29 @@ +// From https://github.com/streamich/fast-shallow-equal + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function isShallowEqual(a: any, b: any) { + if (a === b) { + return true; + } + + if (!(a instanceof Object) || !(b instanceof Object)) { + return false; + } + + var keys = Object.keys(a); + var length = keys.length; + + for (let i = 0; i < length; i++) { + if (!(keys[i] in b)) { + return false; + } + } + + for (let i = 0; i < length; i++) { + if (a[keys[i]] !== b[keys[i]]) { + return false; + } + } + + return length === Object.keys(b).length; +} diff --git a/public/app/features/admin/AdminEditOrgPage.tsx b/public/app/features/admin/AdminEditOrgPage.tsx index 44bc10e0134..8ab6d856d77 100644 --- a/public/app/features/admin/AdminEditOrgPage.tsx +++ b/public/app/features/admin/AdminEditOrgPage.tsx @@ -6,7 +6,7 @@ import { useAsyncFn } from 'react-use'; import { UrlQueryValue } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; import { Form, Field, Input, Button, Legend, Alert } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getNavModel } from 'app/core/selectors/navModel'; diff --git a/public/app/features/admin/AdminListOrgsPage.tsx b/public/app/features/admin/AdminListOrgsPage.tsx index 6fff515aa09..94757ff3715 100644 --- a/public/app/features/admin/AdminListOrgsPage.tsx +++ b/public/app/features/admin/AdminListOrgsPage.tsx @@ -4,7 +4,7 @@ import useAsyncFn from 'react-use/lib/useAsyncFn'; import { getBackendSrv } from '@grafana/runtime'; import { LinkButton } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { getNavModel } from 'app/core/selectors/navModel'; import { contextSrv } from 'app/core/services/context_srv'; import { AccessControlAction } from 'app/types'; diff --git a/public/app/features/admin/AdminSettings.tsx b/public/app/features/admin/AdminSettings.tsx index e2e2a0c7ecb..f0b90e6e2b8 100644 --- a/public/app/features/admin/AdminSettings.tsx +++ b/public/app/features/admin/AdminSettings.tsx @@ -4,7 +4,7 @@ import { useAsync } from 'react-use'; import { NavModel } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { getNavModel } from 'app/core/selectors/navModel'; import { StoreState } from 'app/types'; diff --git a/public/app/features/admin/ExportStartButton.tsx b/public/app/features/admin/ExportStartButton.tsx deleted file mode 100644 index f94d4f5b081..00000000000 --- a/public/app/features/admin/ExportStartButton.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { css } from '@emotion/css'; -import React, { useState } from 'react'; - -import { GrafanaTheme2 } from '@grafana/data'; -import { getBackendSrv } from '@grafana/runtime'; -import { Button, CodeEditor, Modal, useTheme2 } from '@grafana/ui'; - -export const ExportStartButton = () => { - const styles = getStyles(useTheme2()); - const [open, setOpen] = useState(false); - const [body, setBody] = useState({ - format: 'git', - git: {}, - }); - const onDismiss = () => setOpen(false); - const doStart = () => { - getBackendSrv() - .post('/api/admin/export', body) - .then((v) => { - console.log('GOT', v); - onDismiss(); - }); - }; - - return ( - <> - -
    - { - setBody(JSON.parse(text)); // force JSON? - }} - /> -
    - - - - -
    - - - - ); -}; - -const getStyles = (theme: GrafanaTheme2) => { - return { - wrap: css` - border: 2px solid #111; - `, - }; -}; diff --git a/public/app/features/admin/ExportStatus.tsx b/public/app/features/admin/ExportStatus.tsx deleted file mode 100644 index 6b638b8f358..00000000000 --- a/public/app/features/admin/ExportStatus.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { css } from '@emotion/css'; -import React, { useEffect, useState } from 'react'; - -import { GrafanaTheme2, isLiveChannelMessageEvent, isLiveChannelStatusEvent, LiveChannelScope } from '@grafana/data'; -import { getBackendSrv, getGrafanaLiveSrv } from '@grafana/runtime'; -import { Button, useTheme2 } from '@grafana/ui'; - -import { ExportStartButton } from './ExportStartButton'; - -interface ExportStatusMessage { - running: boolean; - target: string; - started: number; - finished: number; - update: number; - count: number; - current: number; - last: string; - status: string; -} - -export const ExportStatus = () => { - const styles = getStyles(useTheme2()); - const [status, setStatus] = useState(); - - useEffect(() => { - const subscription = getGrafanaLiveSrv() - .getStream({ - scope: LiveChannelScope.Grafana, - namespace: 'broadcast', - path: 'export', - }) - .subscribe({ - next: (evt) => { - if (isLiveChannelMessageEvent(evt)) { - setStatus(evt.message); - } else if (isLiveChannelStatusEvent(evt)) { - setStatus(evt.message); - } - }, - }); - return () => { - subscription.unsubscribe(); - }; - }, []); - - if (!status) { - return ( -
    - -
    - ); - } - - return ( -
    -
    {JSON.stringify(status, null, 2)}
    - {Boolean(!status.running) && } - {Boolean(status.running) && ( - - )} -
    - ); -}; - -const getStyles = (theme: GrafanaTheme2) => { - return { - wrap: css` - border: 4px solid red; - `, - running: css` - border: 4px solid green; - `, - }; -}; diff --git a/public/app/features/admin/ServerStats.tsx b/public/app/features/admin/ServerStats.tsx index 1b3cf7799d6..672d1ba2746 100644 --- a/public/app/features/admin/ServerStats.tsx +++ b/public/app/features/admin/ServerStats.tsx @@ -10,7 +10,6 @@ import { contextSrv } from '../../core/services/context_srv'; import { Loader } from '../plugins/admin/components/Loader'; import { CrawlerStatus } from './CrawlerStatus'; -import { ExportStatus } from './ExportStatus'; import { getServerStats, ServerStat } from './state/apis'; export const ServerStats = () => { @@ -99,7 +98,6 @@ export const ServerStats = () => { )} {config.featureToggles.dashboardPreviews && config.featureToggles.dashboardPreviewsAdmin && } - {config.featureToggles.export && } ); }; diff --git a/public/app/features/admin/UpgradePage.tsx b/public/app/features/admin/UpgradePage.tsx index 3678cb244c6..07b93187d28 100644 --- a/public/app/features/admin/UpgradePage.tsx +++ b/public/app/features/admin/UpgradePage.tsx @@ -4,8 +4,8 @@ import { connect } from 'react-redux'; import { GrafanaTheme2, NavModel } from '@grafana/data'; import { LinkButton, useStyles2 } from '@grafana/ui'; +import { Page } from 'app/core/components/Page/Page'; -import Page from '../../core/components/Page/Page'; import { getNavModel } from '../../core/selectors/navModel'; import { StoreState } from '../../types'; diff --git a/public/app/features/admin/UserAdminPage.tsx b/public/app/features/admin/UserAdminPage.tsx index 264e9ce943a..14865464d1e 100644 --- a/public/app/features/admin/UserAdminPage.tsx +++ b/public/app/features/admin/UserAdminPage.tsx @@ -3,7 +3,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { NavModel } from '@grafana/data'; import { featureEnabled } from '@grafana/runtime'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getNavModel } from 'app/core/selectors/navModel'; diff --git a/public/app/features/admin/UserCreatePage.tsx b/public/app/features/admin/UserCreatePage.tsx index b98e82a3e6a..e68a20eeebf 100644 --- a/public/app/features/admin/UserCreatePage.tsx +++ b/public/app/features/admin/UserCreatePage.tsx @@ -5,7 +5,7 @@ import { useHistory } from 'react-router-dom'; import { NavModel } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; import { Form, Button, Input, Field } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { getNavModel } from '../../core/selectors/navModel'; import { StoreState } from '../../types'; diff --git a/public/app/features/admin/UserListAdminPage.tsx b/public/app/features/admin/UserListAdminPage.tsx index 7fa5809ae4f..f7435d397df 100644 --- a/public/app/features/admin/UserListAdminPage.tsx +++ b/public/app/features/admin/UserListAdminPage.tsx @@ -13,7 +13,7 @@ import { useStyles2, FilterInput, } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; import { contextSrv } from 'app/core/core'; diff --git a/public/app/features/admin/ldap/LdapPage.tsx b/public/app/features/admin/ldap/LdapPage.tsx index 28adbdc13ad..f0959e6f70e 100644 --- a/public/app/features/admin/ldap/LdapPage.tsx +++ b/public/app/features/admin/ldap/LdapPage.tsx @@ -5,7 +5,7 @@ import { NavModel } from '@grafana/data'; import { featureEnabled } from '@grafana/runtime'; import { Alert, Button, LegacyForms } from '@grafana/ui'; const { FormField } = LegacyForms; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getNavModel } from 'app/core/selectors/navModel'; diff --git a/public/app/features/alerting/AlertRuleList.tsx b/public/app/features/alerting/AlertRuleList.tsx index 10de691e571..a19d2ab7bbd 100644 --- a/public/app/features/alerting/AlertRuleList.tsx +++ b/public/app/features/alerting/AlertRuleList.tsx @@ -5,7 +5,7 @@ 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 { Page } from 'app/core/components/Page/Page'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getNavModel } from 'app/core/selectors/navModel'; import { AlertRule, StoreState } from 'app/types'; diff --git a/public/app/features/alerting/EditNotificationChannelPage.tsx b/public/app/features/alerting/EditNotificationChannelPage.tsx index 45aba248222..7303f7ccfd3 100644 --- a/public/app/features/alerting/EditNotificationChannelPage.tsx +++ b/public/app/features/alerting/EditNotificationChannelPage.tsx @@ -4,7 +4,7 @@ import { MapDispatchToProps, MapStateToProps } from 'react-redux'; import { NavModel } from '@grafana/data'; import { config } from '@grafana/runtime'; import { Form, Spinner } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { connectWithCleanUp } from 'app/core/components/connectWithCleanUp'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getNavModel } from 'app/core/selectors/navModel'; diff --git a/public/app/features/alerting/FeatureTogglePage.tsx b/public/app/features/alerting/FeatureTogglePage.tsx index 63f62796f94..e6def5ec3f5 100644 --- a/public/app/features/alerting/FeatureTogglePage.tsx +++ b/public/app/features/alerting/FeatureTogglePage.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { useNavModel } from 'app/core/hooks/useNavModel'; export default function FeatureTogglePage() { diff --git a/public/app/features/alerting/NewNotificationChannelPage.tsx b/public/app/features/alerting/NewNotificationChannelPage.tsx index 418029fb2e5..93d6ca43865 100644 --- a/public/app/features/alerting/NewNotificationChannelPage.tsx +++ b/public/app/features/alerting/NewNotificationChannelPage.tsx @@ -3,7 +3,7 @@ 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 { Page } from 'app/core/components/Page/Page'; import { getNavModel } from 'app/core/selectors/navModel'; import { NotificationChannelDTO, StoreState } from '../../types'; diff --git a/public/app/features/alerting/NotificationsListPage.tsx b/public/app/features/alerting/NotificationsListPage.tsx index 9a5e5b3f4eb..6f11e260aac 100644 --- a/public/app/features/alerting/NotificationsListPage.tsx +++ b/public/app/features/alerting/NotificationsListPage.tsx @@ -4,7 +4,7 @@ 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 { 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'; diff --git a/public/app/features/alerting/unified/RuleEditor.tsx b/public/app/features/alerting/unified/RuleEditor.tsx index 57d66fc319f..e550148abbe 100644 --- a/public/app/features/alerting/unified/RuleEditor.tsx +++ b/public/app/features/alerting/unified/RuleEditor.tsx @@ -5,7 +5,7 @@ import { useAsync } from 'react-use'; import { GrafanaTheme2 } from '@grafana/data'; import { Alert, LinkButton, LoadingPlaceholder, useStyles2, withErrorBoundary } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { useCleanup } from 'app/core/hooks/useCleanup'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { RuleIdentifier } from 'app/types/unified-alerting'; diff --git a/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx b/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx index 21a7826139e..bb87e4a7a4e 100644 --- a/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx +++ b/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx @@ -1,7 +1,7 @@ import React, { FC } from 'react'; import { useSelector } from 'react-redux'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { getNavModel } from 'app/core/selectors/navModel'; import { StoreState } from 'app/types/store'; diff --git a/public/app/features/alerting/unified/utils/matchers.test.ts b/public/app/features/alerting/unified/utils/matchers.test.ts index 0a073dde7c4..09a54217fb7 100644 --- a/public/app/features/alerting/unified/utils/matchers.test.ts +++ b/public/app/features/alerting/unified/utils/matchers.test.ts @@ -56,27 +56,38 @@ describe('Unified Alerting matchers', () => { }); it('should match for regex', () => { - const matchers = [{ name: 'foo', value: 'bar', operator: MatcherOperator.regex }]; + const matchers = [{ name: 'foo', value: 'b{1}a.*', operator: MatcherOperator.regex }]; const alerts = [ + mockPromAlert({ labels: { foo: 'bbr' } }), + mockPromAlert({ labels: { foo: 'aba' } }), // This does not match because the regex is implicitly anchored. + mockPromAlert({ labels: { foo: 'ba' } }), mockPromAlert({ labels: { foo: 'bar' } }), mockPromAlert({ labels: { foo: 'baz' } }), mockPromAlert({ labels: { foo: 'bas' } }), ]; const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers); - expect(matchedAlerts).toHaveLength(1); + expect(matchedAlerts).toHaveLength(4); + expect(matchedAlerts.map((instance) => instance.data.matchedInstance.labels.foo)).toEqual([ + 'ba', + 'bar', + 'baz', + 'bas', + ]); }); it('should not match regex', () => { - const matchers = [{ name: 'foo', value: 'bar', operator: MatcherOperator.notRegex }]; + const matchers = [{ name: 'foo', value: 'ba{3}', operator: MatcherOperator.notRegex }]; const alerts = [ mockPromAlert({ labels: { foo: 'bar' } }), mockPromAlert({ labels: { foo: 'baz' } }), + mockPromAlert({ labels: { foo: 'baaa' } }), mockPromAlert({ labels: { foo: 'bas' } }), ]; const matchedAlerts = findAlertInstancesWithMatchers(alerts, matchers); - expect(matchedAlerts).toHaveLength(2); + expect(matchedAlerts).toHaveLength(3); + expect(matchedAlerts.map((instance) => instance.data.matchedInstance.labels.foo)).toEqual(['bar', 'baz', 'bas']); }); }); }); diff --git a/public/app/features/alerting/unified/utils/matchers.ts b/public/app/features/alerting/unified/utils/matchers.ts index 66fe014b2bc..d8889638f58 100644 --- a/public/app/features/alerting/unified/utils/matchers.ts +++ b/public/app/features/alerting/unified/utils/matchers.ts @@ -41,29 +41,44 @@ export const findAlertInstancesWithMatchers = ( instances: Alert[], matchers: MatcherFieldValue[] ): MatchedInstance[] => { - const hasMatcher = (instance: Alert, matcher: MatcherFieldValue) => { + const anchorRegex = (regexpString: string): RegExp => { + // Silence matchers are always fully anchored in the Alertmanager: https://github.com/prometheus/alertmanager/pull/748 + if (!regexpString.startsWith('^')) { + regexpString = '^' + regexpString; + } + if (!regexpString.endsWith('$')) { + regexpString = regexpString + '$'; + } + return new RegExp(regexpString); + }; + + const matchesInstance = (instance: Alert, matcher: MatcherFieldValue) => { return Object.entries(instance.labels).some(([key, value]) => { if (!matcher.name || !matcher.value) { return false; } - if (matcher.operator === MatcherOperator.equal) { - return matcher.name === key && matcher.value === value; + if (matcher.name !== key) { + return false; } - if (matcher.operator === MatcherOperator.notEqual) { - return matcher.name === key && matcher.value !== value; + switch (matcher.operator) { + case MatcherOperator.equal: + return matcher.value === value; + case MatcherOperator.notEqual: + return matcher.value !== value; + case MatcherOperator.regex: + const regex = anchorRegex(matcher.value); + return regex.test(value); + case MatcherOperator.notRegex: + const negregex = anchorRegex(matcher.value); + return !negregex.test(value); + default: + return false; } - if (matcher.operator === MatcherOperator.regex) { - return matcher.name === key && matcher.value.match(value); - } - if (matcher.operator === MatcherOperator.notRegex) { - return matcher.name === key && !matcher.value.match(value); - } - return false; }); }; const filteredInstances = instances.filter((instance) => { - return matchers.every((matcher) => hasMatcher(instance, matcher)); + return matchers.every((matcher) => matchesInstance(instance, matcher)); }); const mappedInstances = filteredInstances.map((instance) => ({ id: `${instance.activeAt}-${instance.value}`, diff --git a/public/app/features/api-keys/ApiKeysPage.tsx b/public/app/features/api-keys/ApiKeysPage.tsx index 9df08efd22e..4cb20857620 100644 --- a/public/app/features/api-keys/ApiKeysPage.tsx +++ b/public/app/features/api-keys/ApiKeysPage.tsx @@ -6,7 +6,7 @@ import { rangeUtil } from '@grafana/data'; import { InlineField, InlineSwitch, VerticalGroup } from '@grafana/ui'; import appEvents from 'app/core/app_events'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import config from 'app/core/config'; import { contextSrv } from 'app/core/core'; import { getNavModel } from 'app/core/selectors/navModel'; diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index 09101d40d5a..d306ad6022b 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -5,7 +5,7 @@ import { useLocation } from 'react-router-dom'; import { locationUtil, textUtil } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { ButtonGroup, ModalsController, ToolbarButton, PageToolbar, useForceUpdate } from '@grafana/ui'; -import { TopNavUpdate } from 'app/core/components/TopNav/TopNavUpdate'; +import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; import config from 'app/core/config'; import { toggleKioskMode } from 'app/core/navigation/kiosk'; import { DashboardCommentsModal } from 'app/features/dashboard/components/DashboardComments/DashboardCommentsModal'; @@ -277,8 +277,8 @@ export const DashNav = React.memo((props) => { if (config.featureToggles.topnav) { return ( - } /> ); diff --git a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx index 363eae8acc6..b709ec8a7ff 100644 --- a/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx +++ b/public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx @@ -161,7 +161,7 @@ export function DashboardSettings({ dashboard, editview }: Props) { const styles = getStyles(config.theme2); return ( - +
    diff --git a/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategory.tsx b/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategory.tsx index 8cfa6969013..baa2878f749 100644 --- a/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategory.tsx +++ b/public/app/features/dashboard/components/PanelEditor/OptionsPaneCategory.tsx @@ -57,9 +57,12 @@ export const OptionsPaneCategory: FC = React.memo( const onToggle = useCallback(() => { manualClickTime.current = Date.now(); - updateQueryParams({ - [CATEGORY_PARAM_NAME]: isExpanded ? undefined : id, - }); + updateQueryParams( + { + [CATEGORY_PARAM_NAME]: isExpanded ? undefined : id, + }, + true + ); setSavedState({ isExpanded: !isExpanded }); setIsExpanded(!isExpanded); }, [setSavedState, setIsExpanded, updateQueryParams, isExpanded, id]); diff --git a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.tsx b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.tsx index 145e659574a..6c42a952944 100644 --- a/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.tsx +++ b/public/app/features/dashboard/components/ShareModal/SharePublicDashboard.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { AppEvents } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime/src'; -import { Alert, Button, Checkbox, ClipboardButton, Field, FieldSet, Icon, Input, Switch } from '@grafana/ui'; +import { Alert, Button, Checkbox, ClipboardButton, Field, FieldSet, Input, LinkButton, Switch } from '@grafana/ui'; import { notifyApp } from 'app/core/actions'; import { createErrorNotification } from 'app/core/copy/appNotification'; import { appEvents } from 'app/core/core'; @@ -45,6 +45,16 @@ export const SharePublicDashboard = (props: Props) => { getPublicDashboardConfig(props.dashboard.uid, setPublicDashboardConfig).catch(); }, [props.dashboard.uid]); + useEffect(() => { + if (publicDashboardPersisted(publicDashboard)) { + setAcknowledgements({ + public: true, + datasources: true, + usage: true, + }); + } + }, [publicDashboard]); + const onSavePublicConfig = () => { reportInteraction('grafana_dashboards_public_create_clicked'); @@ -97,103 +107,119 @@ export const SharePublicDashboard = (props: Props) => { github discussion
    - {!publicDashboardPersisted(publicDashboard) && ( -
    - Before you click Save, please acknowledge the following information:
    -
    -
    -
    - onAcknowledge('public', e.currentTarget.checked)} - /> -
    -
    -
    - onAcknowledge('datasources', e.currentTarget.checked)} - /> -
    -
    +
    + Before you click Save, please acknowledge the following information:
    +
    +
    +
    onAcknowledge('usage', e.currentTarget.checked)} + label="Your entire dashboard will be public" + value={acknowledgements.public} + disabled={publicDashboardPersisted(publicDashboard)} + onChange={(e) => onAcknowledge('public', e.currentTarget.checked)} /> -
    -
    -
    -
    - )} - {(publicDashboardPersisted(publicDashboard) || acknowledged()) && ( -
    -

    Public Dashboard Configuration

    -
    - Time Range -
    -
    - - From: - - } - /> - To: - } - /> -
    -
    - - { - reportInteraction('grafana_dashboards_public_enable_clicked', { - action: publicDashboard?.isEnabled ? 'disable' : 'enable', - }); +
    +
    +
    + onAcknowledge('datasources', e.currentTarget.checked)} + /> + +
    +
    + onAcknowledge('usage', e.currentTarget.checked)} + /> + +
    +
    +
    +
    +
    +

    Public Dashboard Configuration

    +
    + Time Range +
    +
    + From: + } + /> + To: + } + /> +
    +
    + + { + reportInteraction('grafana_dashboards_public_enable_clicked', { + action: publicDashboard?.isEnabled ? 'disable' : 'enable', + }); - setPublicDashboardConfig({ - ...publicDashboard, - isEnabled: !publicDashboard.isEnabled, - }); - }} + setPublicDashboardConfig({ + ...publicDashboard, + isEnabled: !publicDashboard.isEnabled, + }); + }} + /> + + {publicDashboardPersisted(publicDashboard) && publicDashboard.isEnabled && ( + + { + return generatePublicDashboardUrl(publicDashboard); + }} + onClipboardCopy={onShareUrlCopy} + > + Copy + + } /> - {publicDashboardPersisted(publicDashboard) && publicDashboard.isEnabled && ( - - { - return generatePublicDashboardUrl(publicDashboard); - }} - onClipboardCopy={onShareUrlCopy} - > - Copy - - } - /> - - )} -
    - -
    - )} + )} + + +
    )} diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index d32eb5c1817..017e6adc8a1 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -5,7 +5,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { GrafanaTheme2, TimeRange } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { locationService } from '@grafana/runtime'; +import { config, locationService } from '@grafana/runtime'; import { CustomScrollbar, stylesFactory, Themeable2, withTheme2 } from '@grafana/ui'; import { notifyApp } from 'app/core/actions'; import { Branding } from 'app/core/components/Branding/Branding'; @@ -387,7 +387,8 @@ export class UnthemedDashboardPage extends PureComponent { * Styles */ export const getStyles = stylesFactory((theme: GrafanaTheme2, kioskMode: KioskMode) => { - const contentPadding = kioskMode !== KioskMode.Full ? theme.spacing(0, 2, 2) : theme.spacing(2); + const contentPadding = + kioskMode === KioskMode.Full || config.featureToggles.topnav ? theme.spacing(2) : theme.spacing(0, 2, 2); return { dashboardContainer: css` width: 100%; diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx index 7d8eff8de7d..c5bf73cf32b 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx @@ -3,7 +3,7 @@ import React, { FC } from 'react'; import { DataLink, GrafanaTheme2, PanelData } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { Icon, useStyles2 } from '@grafana/ui'; +import { Icon, useStyles2, ClickOutsideWrapper } from '@grafana/ui'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { PanelModel } from 'app/features/dashboard/state/PanelModel'; import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers'; @@ -48,34 +48,33 @@ export const PanelHeader: FC = ({ panel, error, isViewing, isEditing, dat {({ closeMenu, panelMenuOpen }) => { return ( -
    - - {alertState ? ( - - ) : null} -

    {title}

    - {!dashboard.meta.publicDashboardAccessToken && ( -
    - - +
    + + {alertState ? ( + -
    - )} - {data.request && data.request.timeInfo && ( - - {data.request.timeInfo} - - )} -
    + ) : null} +

    {title}

    + {!dashboard.meta.publicDashboardAccessToken && ( +
    + + {panelMenuOpen ? ( + + ) : null} +
    + )} + {data.request && data.request.timeInfo && ( + + {data.request.timeInfo} + + )} +
    + ); }}
    diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx index 174c07a2d49..8a52e5cec03 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuTrigger.tsx @@ -14,18 +14,18 @@ interface Props extends HTMLAttributes { export const PanelHeaderMenuTrigger: FC = ({ children, ...divProps }) => { const [clickCoordinates, setClickCoordinates] = useState({ x: 0, y: 0 }); const [panelMenuOpen, setPanelMenuOpen] = useState(false); + const onMenuToggle = useCallback( (event: MouseEvent) => { if (!isClick(clickCoordinates, eventToClickCoordinates(event))) { return; } - event.stopPropagation(); - setPanelMenuOpen(!panelMenuOpen); }, [clickCoordinates, panelMenuOpen, setPanelMenuOpen] ); + const onMouseDown = useCallback( (event: MouseEvent) => { setClickCoordinates(eventToClickCoordinates(event)); diff --git a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx index 40d69a5efe7..cc2ac9b90b8 100644 --- a/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx +++ b/public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenuWrapper.tsx @@ -1,7 +1,5 @@ import React, { FC } from 'react'; -import { ClickOutsideWrapper } from '@grafana/ui'; - import { DashboardModel, PanelModel } from '../../state'; import { PanelHeaderMenu } from './PanelHeaderMenu'; @@ -10,22 +8,15 @@ import { PanelHeaderMenuProvider } from './PanelHeaderMenuProvider'; interface Props { panel: PanelModel; dashboard: DashboardModel; - show: boolean; onClose: () => void; } -export const PanelHeaderMenuWrapper: FC = ({ show, onClose, panel, dashboard }) => { - if (!show) { - return null; - } - +export const PanelHeaderMenuWrapper: FC = ({ panel, dashboard }) => { return ( - - - {({ items }) => { - return ; - }} - - + + {({ items }) => { + return ; + }} + ); }; diff --git a/public/app/features/dashboard/services/PublicDashboardDataSource.ts b/public/app/features/dashboard/services/PublicDashboardDataSource.ts index 7a384b0835d..52e89d55fba 100644 --- a/public/app/features/dashboard/services/PublicDashboardDataSource.ts +++ b/public/app/features/dashboard/services/PublicDashboardDataSource.ts @@ -1,19 +1,41 @@ import { catchError, Observable, of, switchMap } from 'rxjs'; -import { DataQuery, DataQueryRequest, DataQueryResponse, DataSourceApi, PluginMeta } from '@grafana/data'; +import { + DataQuery, + DataQueryRequest, + DataQueryResponse, + DataSourceApi, + DataSourceRef, + PluginMeta, +} from '@grafana/data'; import { BackendDataSourceResponse, getBackendSrv, toDataQueryResponse } from '@grafana/runtime'; +export const PUBLIC_DATASOURCE = '-- Public --'; + export class PublicDashboardDataSource extends DataSourceApi { - constructor() { + constructor(datasource: DataSourceRef | string | DataSourceApi | null) { super({ name: 'public-ds', - id: 1, + id: 0, type: 'public-ds', meta: {} as PluginMeta, - uid: '1', + uid: PublicDashboardDataSource.resolveUid(datasource), jsonData: {}, access: 'proxy', }); + + this.interval = '1min'; + } + + /** + * Get the datasource uid based on the many types a datasource can be. + */ + private static resolveUid(datasource: DataSourceRef | string | DataSourceApi | null): string { + if (typeof datasource === 'string') { + return datasource; + } + + return datasource?.uid ?? PUBLIC_DATASOURCE; } /** diff --git a/public/app/features/dashboard/services/TimeSrv.test.ts b/public/app/features/dashboard/services/TimeSrv.test.ts index c508b100c60..c9d4b6b341e 100644 --- a/public/app/features/dashboard/services/TimeSrv.test.ts +++ b/public/app/features/dashboard/services/TimeSrv.test.ts @@ -253,24 +253,18 @@ describe('timeSrv', () => { }); describe('pauseAutoRefresh', () => { - it('should set refresh to empty value', () => { + it('should set autoRefreshPaused to true', () => { _dashboard.refresh = '10s'; timeSrv.pauseAutoRefresh(); - expect(_dashboard.refresh).toBe(''); - }); - - it('should set previousAutoRefresh value', () => { - _dashboard.refresh = '10s'; - timeSrv.pauseAutoRefresh(); - expect(timeSrv.previousAutoRefresh).toBe('10s'); + expect(timeSrv.autoRefreshPaused).toBe(true); }); }); describe('resumeAutoRefresh', () => { it('should set refresh to empty value', () => { - timeSrv.previousAutoRefresh = '10s'; + timeSrv.autoRefreshPaused = true; timeSrv.resumeAutoRefresh(); - expect(_dashboard.refresh).toBe('10s'); + expect(timeSrv.autoRefreshPaused).toBe(false); }); }); diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts index cf3b7e7f6e1..e9792b20e9e 100644 --- a/public/app/features/dashboard/services/TimeSrv.ts +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -24,7 +24,7 @@ export class TimeSrv { time: any; refreshTimer: any; refresh: any; - previousAutoRefresh: any; + autoRefreshPaused = false; oldRefresh: string | null | undefined; timeModel?: TimeModel; timeAtLoad: any; @@ -232,7 +232,7 @@ export class TimeSrv { this.refreshTimer = setTimeout(() => { this.startNextRefreshTimer(intervalMs); - this.refreshTimeModel(); + !this.autoRefreshPaused && this.refreshTimeModel(); }, intervalMs); const refresh = this.contextSrv.getValidInterval(interval); @@ -250,7 +250,7 @@ export class TimeSrv { this.refreshTimer = setTimeout(() => { this.startNextRefreshTimer(afterMs); if (this.contextSrv.isGrafanaVisible()) { - this.refreshTimeModel(); + !this.autoRefreshPaused && this.refreshTimeModel(); } else { this.autoRefreshBlocked = true; } @@ -264,13 +264,13 @@ export class TimeSrv { // store timeModel refresh value and pause auto-refresh in some places // i.e panel edit pauseAutoRefresh() { - this.previousAutoRefresh = this.timeModel?.refresh; - this.setAutoRefresh(''); + this.autoRefreshPaused = true; } // resume auto-refresh based on old dashboard refresh property resumeAutoRefresh() { - this.setAutoRefresh(this.previousAutoRefresh); + this.autoRefreshPaused = false; + this.refreshTimeModel(); } setTime(time: RawTimeRange, updateUrl = true) { diff --git a/public/app/features/dashboard/state/DashboardModel.test.ts b/public/app/features/dashboard/state/DashboardModel.test.ts index da4e8165a8e..77e0c59af21 100644 --- a/public/app/features/dashboard/state/DashboardModel.test.ts +++ b/public/app/features/dashboard/state/DashboardModel.test.ts @@ -976,7 +976,7 @@ describe('exitViewPanel', () => { }); describe('exitPanelEditor', () => { - function getTestContext(setPreviousAutoRefresh = false) { + function getTestContext(pauseAutoRefresh = false) { const panel: any = { destroy: jest.fn() }; const dashboard = new DashboardModel({}); const timeSrvMock = { @@ -986,8 +986,8 @@ describe('exitPanelEditor', () => { } as unknown as TimeSrv; dashboard.startRefresh = jest.fn(); dashboard.panelInEdit = panel; - if (setPreviousAutoRefresh) { - timeSrvMock.previousAutoRefresh = '5s'; + if (pauseAutoRefresh) { + timeSrvMock.autoRefreshPaused = true; } setTimeSrv(timeSrvMock); return { dashboard, panel, timeSrvMock }; diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 13b1a51d5f4..2bd035411c1 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -1108,6 +1108,10 @@ export class DashboardModel implements TimeModel { } } + getDefaultTime() { + return this.originalTime; + } + private getPanelRepeatVariable(panel: PanelModel) { return this.getVariablesFromState(this.uid).find((variable) => variable.name === panel.repeat); } diff --git a/public/app/features/datasources/DataSourceDashboards.tsx b/public/app/features/datasources/DataSourceDashboards.tsx index 9673716ddc3..9033a94ecc6 100644 --- a/public/app/features/datasources/DataSourceDashboards.tsx +++ b/public/app/features/datasources/DataSourceDashboards.tsx @@ -1,7 +1,7 @@ import React, { PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getNavModel } from 'app/core/selectors/navModel'; import { PluginDashboard, StoreState } from 'app/types'; diff --git a/public/app/features/datasources/DataSourcesListPage.tsx b/public/app/features/datasources/DataSourcesListPage.tsx index f03e7ede1af..4535c5b0f76 100644 --- a/public/app/features/datasources/DataSourcesListPage.tsx +++ b/public/app/features/datasources/DataSourcesListPage.tsx @@ -3,7 +3,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { IconName } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import PageActionBar from 'app/core/components/PageActionBar/PageActionBar'; import { contextSrv } from 'app/core/core'; import { getNavModel } from 'app/core/selectors/navModel'; diff --git a/public/app/features/datasources/NewDataSourcePage.tsx b/public/app/features/datasources/NewDataSourcePage.tsx index b7943e8af90..2f226c69784 100644 --- a/public/app/features/datasources/NewDataSourcePage.tsx +++ b/public/app/features/datasources/NewDataSourcePage.tsx @@ -5,7 +5,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { DataSourcePluginMeta, GrafanaTheme2, NavModel } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Card, LinkButton, List, PluginSignatureBadge, FilterInput, useStyles2 } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { StoreState } from 'app/types'; import { PluginsErrorsInfo } from '../plugins/components/PluginsErrorsInfo'; diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx index 68e2a4f2384..3630fb0d48f 100644 --- a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx @@ -6,7 +6,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { Alert, Button } from '@grafana/ui'; import { cleanUpAction } from 'app/core/actions/cleanUp'; import appEvents from 'app/core/app_events'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getNavModel } from 'app/core/selectors/navModel'; diff --git a/public/app/features/explore/FeatureTogglePage.tsx b/public/app/features/explore/FeatureTogglePage.tsx index 54a00691aaa..a10df73f183 100644 --- a/public/app/features/explore/FeatureTogglePage.tsx +++ b/public/app/features/explore/FeatureTogglePage.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; export default function FeatureTogglePage() { const styles = useStyles2( diff --git a/public/app/features/explore/TraceView/TraceView.test.tsx b/public/app/features/explore/TraceView/TraceView.test.tsx index 3cdb9e82994..c50f6207d63 100644 --- a/public/app/features/explore/TraceView/TraceView.test.tsx +++ b/public/app/features/explore/TraceView/TraceView.test.tsx @@ -92,14 +92,14 @@ describe('TraceView', () => { it('toggles detailState', async () => { renderTraceViewNew(); - expect(screen.queryByText(/Tags/)).toBeFalsy(); + expect(screen.queryByText(/Attributes/)).toBeFalsy(); const spanView = screen.getAllByText('', { selector: 'div[data-testid="span-view"]' })[0]; await userEvent.click(spanView); - expect(screen.queryByText(/Tags/)).toBeTruthy(); + expect(screen.queryByText(/Attributes/)).toBeTruthy(); await userEvent.click(spanView); - screen.debug(screen.queryAllByText(/Tags/)); - expect(screen.queryByText(/Tags/)).toBeFalsy(); + screen.debug(screen.queryAllByText(/Attributes/)); + expect(screen.queryByText(/Attributes/)).toBeFalsy(); }); it('shows timeline ticks', () => { @@ -118,21 +118,21 @@ describe('TraceView', () => { const firstSpan = screen.getAllByText('', { selector: 'div[data-testid="span-view"]' })[0]; await userEvent.click(firstSpan); - await userEvent.click(screen.getByText(/Process/)); + await userEvent.click(screen.getByText(/Resource/)); table = screen.getByText('', { selector: 'div[data-testid="KeyValueTable"]' }); expect(table.innerHTML).toContain('client-uuid-1'); await userEvent.click(firstSpan); const secondSpan = screen.getAllByText('', { selector: 'div[data-testid="span-view"]' })[1]; await userEvent.click(secondSpan); - await userEvent.click(screen.getByText(/Process/)); + await userEvent.click(screen.getByText(/Resource/)); table = screen.getByText('', { selector: 'div[data-testid="KeyValueTable"]' }); expect(table.innerHTML).toContain('client-uuid-2'); await userEvent.click(secondSpan); const thirdSpan = screen.getAllByText('', { selector: 'div[data-testid="span-view"]' })[2]; await userEvent.click(thirdSpan); - await userEvent.click(screen.getByText(/Process/)); + await userEvent.click(screen.getByText(/Resource/)); table = screen.getByText('', { selector: 'div[data-testid="KeyValueTable"]' }); expect(table.innerHTML).toContain('client-uuid-3'); }); @@ -142,10 +142,10 @@ describe('TraceView', () => { const span = screen.getAllByText('', { selector: 'div[data-testid="span-view"]' })[2]; await userEvent.click(span); //Process is in detail view - expect(screen.getByText(/Process/)).toBeInTheDocument(); + expect(screen.getByText(/Resource/)).toBeInTheDocument(); rerender(getTraceView([frameNew])); - expect(screen.queryByText(/Process/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Resource/)).not.toBeInTheDocument(); }); }); diff --git a/public/app/features/explore/TraceView/TraceView.tsx b/public/app/features/explore/TraceView/TraceView.tsx index 360bb1f6de5..4ca6fe699da 100644 --- a/public/app/features/explore/TraceView/TraceView.tsx +++ b/public/app/features/explore/TraceView/TraceView.tsx @@ -18,7 +18,13 @@ import { } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; -import { Trace, TracePageHeader, TraceTimelineViewer, TTraceTimeline } from '@jaegertracing/jaeger-ui-components'; +import { + SpanBarOptionsData, + Trace, + TracePageHeader, + TraceTimelineViewer, + TTraceTimeline, +} from '@jaegertracing/jaeger-ui-components'; import { TraceToLogsData } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; import { TraceToMetricsData } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; @@ -118,6 +124,7 @@ export function TraceView(props: Props) { const instanceSettings = getDatasourceSrv().getInstanceSettings(datasource?.name); const traceToLogsOptions = (instanceSettings?.jsonData as TraceToLogsData)?.tracesToLogs; const traceToMetricsOptions = (instanceSettings?.jsonData as TraceToMetricsData)?.tracesToMetrics; + const spanBarOptions: SpanBarOptionsData | undefined = instanceSettings?.jsonData; const createSpanLink = useMemo( () => @@ -155,6 +162,7 @@ export function TraceView(props: Props) { scrollToFirstVisibleSpan={noop} findMatchesIDs={spanFindMatches} trace={traceProp} + spanBarOptions={spanBarOptions?.spanBar} traceTimeline={traceTimeline} updateNextViewRangeTime={updateNextViewRangeTime} updateViewRangeTime={updateViewRangeTime} diff --git a/public/app/features/explore/TraceView/createSpanLink.tsx b/public/app/features/explore/TraceView/createSpanLink.tsx index 57db5e73979..256efe998b7 100644 --- a/public/app/features/explore/TraceView/createSpanLink.tsx +++ b/public/app/features/explore/TraceView/createSpanLink.tsx @@ -87,10 +87,10 @@ function legacyCreateSpanLinkFactory( createFocusSpanLink?: (traceId: string, spanId: string) => LinkModel ) { let logsDataSourceSettings: DataSourceInstanceSettings | undefined; - const isSplunkDS = logsDataSourceSettings?.type === 'grafana-splunk-datasource'; if (traceToLogsOptions?.datasourceUid) { logsDataSourceSettings = getDatasourceSrv().getInstanceSettings(traceToLogsOptions.datasourceUid); } + const isSplunkDS = logsDataSourceSettings?.type === 'grafana-splunk-datasource'; let metricsDataSourceSettings: DataSourceInstanceSettings | undefined; if (traceToMetricsOptions?.datasourceUid) { diff --git a/public/app/features/folders/AccessControlFolderPermissions.tsx b/public/app/features/folders/AccessControlFolderPermissions.tsx index 7e5e1b4d3e5..325b69eb406 100644 --- a/public/app/features/folders/AccessControlFolderPermissions.tsx +++ b/public/app/features/folders/AccessControlFolderPermissions.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { Permissions } from 'app/core/components/AccessControl'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getNavModel } from 'app/core/selectors/navModel'; diff --git a/public/app/features/folders/FolderAlerting.tsx b/public/app/features/folders/FolderAlerting.tsx index 939676a751c..25785b74949 100644 --- a/public/app/features/folders/FolderAlerting.tsx +++ b/public/app/features/folders/FolderAlerting.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useAsync } from 'react-use'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getNavModel } from 'app/core/selectors/navModel'; import { StoreState } from 'app/types'; diff --git a/public/app/features/folders/FolderLibraryPanelsPage.tsx b/public/app/features/folders/FolderLibraryPanelsPage.tsx index aa27c268d76..a645f34bb51 100644 --- a/public/app/features/folders/FolderLibraryPanelsPage.tsx +++ b/public/app/features/folders/FolderLibraryPanelsPage.tsx @@ -2,7 +2,8 @@ import React, { useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { useAsync } from 'react-use'; -import Page from '../../core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; + import { GrafanaRouteComponentProps } from '../../core/navigation/types'; import { getNavModel } from '../../core/selectors/navModel'; import { StoreState } from '../../types'; diff --git a/public/app/features/folders/FolderPermissions.tsx b/public/app/features/folders/FolderPermissions.tsx index ed69935fa4f..59f9263af87 100644 --- a/public/app/features/folders/FolderPermissions.tsx +++ b/public/app/features/folders/FolderPermissions.tsx @@ -3,7 +3,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { Tooltip, Icon, Button } from '@grafana/ui'; import { SlideDown } from 'app/core/components/Animations/SlideDown'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import AddPermission from 'app/core/components/PermissionList/AddPermission'; import PermissionList from 'app/core/components/PermissionList/PermissionList'; import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo'; diff --git a/public/app/features/folders/FolderSettingsPage.tsx b/public/app/features/folders/FolderSettingsPage.tsx index 16cf9a0392a..ecb47bb0de2 100644 --- a/public/app/features/folders/FolderSettingsPage.tsx +++ b/public/app/features/folders/FolderSettingsPage.tsx @@ -4,7 +4,7 @@ import { connect, ConnectedProps } from 'react-redux'; import { Button, LegacyForms } from '@grafana/ui'; const { Input } = LegacyForms; import appEvents from 'app/core/app_events'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getNavModel } from 'app/core/selectors/navModel'; import { StoreState } from 'app/types'; diff --git a/public/app/features/folders/__snapshots__/FolderSettingsPage.test.tsx.snap b/public/app/features/folders/__snapshots__/FolderSettingsPage.test.tsx.snap index 32186afa57a..1a334028040 100644 --- a/public/app/features/folders/__snapshots__/FolderSettingsPage.test.tsx.snap +++ b/public/app/features/folders/__snapshots__/FolderSettingsPage.test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Render should enable save button 1`] = ` -
    - + `; exports[`Render should render component 1`] = ` -
    - + `; diff --git a/public/app/features/folders/components/NewDashboardsFolder.tsx b/public/app/features/folders/components/NewDashboardsFolder.tsx index 72617c8fbfe..cd387a79817 100644 --- a/public/app/features/folders/components/NewDashboardsFolder.tsx +++ b/public/app/features/folders/components/NewDashboardsFolder.tsx @@ -2,7 +2,7 @@ import React, { PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { Button, Input, Form, Field } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { getNavModel } from 'app/core/selectors/navModel'; import { StoreState } from 'app/types'; diff --git a/public/app/features/invites/SignupInvited.tsx b/public/app/features/invites/SignupInvited.tsx index 4d862d6377d..d12da280e07 100644 --- a/public/app/features/invites/SignupInvited.tsx +++ b/public/app/features/invites/SignupInvited.tsx @@ -3,7 +3,7 @@ import { useAsync } from 'react-use'; import { getBackendSrv } from '@grafana/runtime'; import { Button, Field, Form, Input } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { getConfig } from 'app/core/config'; import { contextSrv } from 'app/core/core'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; diff --git a/public/app/features/library-panels/LibraryPanelsPage.tsx b/public/app/features/library-panels/LibraryPanelsPage.tsx index 09342b0e5cf..af435231e7a 100644 --- a/public/app/features/library-panels/LibraryPanelsPage.tsx +++ b/public/app/features/library-panels/LibraryPanelsPage.tsx @@ -1,7 +1,8 @@ import React, { FC, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import Page from '../../core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; + import { GrafanaRouteComponentProps } from '../../core/navigation/types'; import { getNavModel } from '../../core/selectors/navModel'; import { StoreState } from '../../types'; diff --git a/public/app/features/live/pages/CloudAdminPage.tsx b/public/app/features/live/pages/CloudAdminPage.tsx index fe3face9db2..449c4c06c33 100644 --- a/public/app/features/live/pages/CloudAdminPage.tsx +++ b/public/app/features/live/pages/CloudAdminPage.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react'; import { GrafanaTheme } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; import { useStyles } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { useNavModel } from 'app/core/hooks/useNavModel'; import { GrafanaCloudBackend } from './types'; diff --git a/public/app/features/live/pages/FeatureTogglePage.tsx b/public/app/features/live/pages/FeatureTogglePage.tsx index a1d3e635c5a..35b19509a05 100644 --- a/public/app/features/live/pages/FeatureTogglePage.tsx +++ b/public/app/features/live/pages/FeatureTogglePage.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { useNavModel } from 'app/core/hooks/useNavModel'; export default function FeatureTogglePage() { diff --git a/public/app/features/live/pages/LiveStatusPage.tsx b/public/app/features/live/pages/LiveStatusPage.tsx index 569853591af..485aacfe8d5 100644 --- a/public/app/features/live/pages/LiveStatusPage.tsx +++ b/public/app/features/live/pages/LiveStatusPage.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { useNavModel } from 'app/core/hooks/useNavModel'; export default function CloudAdminPage() { diff --git a/public/app/features/live/pages/PipelineAdminPage.tsx b/public/app/features/live/pages/PipelineAdminPage.tsx index 2face908dc6..7d33b38b595 100644 --- a/public/app/features/live/pages/PipelineAdminPage.tsx +++ b/public/app/features/live/pages/PipelineAdminPage.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState, ChangeEvent } from 'react'; import { getBackendSrv } from '@grafana/runtime'; import { Input } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { useNavModel } from 'app/core/hooks/useNavModel'; import { AddNewRule } from './AddNewRule'; diff --git a/public/app/features/manage-dashboards/DashboardImportPage.tsx b/public/app/features/manage-dashboards/DashboardImportPage.tsx index 1085441536a..365a7eabd1b 100644 --- a/public/app/features/manage-dashboards/DashboardImportPage.tsx +++ b/public/app/features/manage-dashboards/DashboardImportPage.tsx @@ -20,7 +20,7 @@ import { withTheme2, } from '@grafana/ui'; import appEvents from 'app/core/app_events'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { getNavModel } from 'app/core/selectors/navModel'; import { StoreState } from 'app/types'; diff --git a/public/app/features/manage-dashboards/SnapshotListPage.tsx b/public/app/features/manage-dashboards/SnapshotListPage.tsx index 5aa91777a34..ae16793ca0f 100644 --- a/public/app/features/manage-dashboards/SnapshotListPage.tsx +++ b/public/app/features/manage-dashboards/SnapshotListPage.tsx @@ -2,7 +2,7 @@ import React, { FC } from 'react'; import { MapStateToProps, connect } from 'react-redux'; import { NavModel } from '@grafana/data'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { getNavModel } from 'app/core/selectors/navModel'; import { StoreState } from 'app/types'; diff --git a/public/app/features/notifications/NotificationsPage.tsx b/public/app/features/notifications/NotificationsPage.tsx index 04b6b192a23..22c64301384 100644 --- a/public/app/features/notifications/NotificationsPage.tsx +++ b/public/app/features/notifications/NotificationsPage.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import Page from '../../core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; + import { GrafanaRouteComponentProps } from '../../core/navigation/types'; import { getNavModel } from '../../core/selectors/navModel'; import { StoreState } from '../../types'; diff --git a/public/app/features/org/NewOrgPage.tsx b/public/app/features/org/NewOrgPage.tsx index 34d2619110b..6a4fc75a490 100644 --- a/public/app/features/org/NewOrgPage.tsx +++ b/public/app/features/org/NewOrgPage.tsx @@ -2,7 +2,7 @@ import React, { FC } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { Button, Input, Field, Form } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { getConfig } from 'app/core/config'; import { StoreState } from 'app/types'; diff --git a/public/app/features/org/OrgDetailsPage.tsx b/public/app/features/org/OrgDetailsPage.tsx index 5059ad5dd25..57c21a83a63 100644 --- a/public/app/features/org/OrgDetailsPage.tsx +++ b/public/app/features/org/OrgDetailsPage.tsx @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { NavModel } from '@grafana/data'; import { VerticalGroup } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import SharedPreferences from 'app/core/components/SharedPreferences/SharedPreferences'; import { contextSrv } from 'app/core/core'; import { getNavModel } from 'app/core/selectors/navModel'; diff --git a/public/app/features/org/SelectOrgPage.tsx b/public/app/features/org/SelectOrgPage.tsx index 273f699cc65..2d39f619074 100644 --- a/public/app/features/org/SelectOrgPage.tsx +++ b/public/app/features/org/SelectOrgPage.tsx @@ -4,7 +4,7 @@ import { useEffectOnce } from 'react-use'; import { config } from '@grafana/runtime'; import { Button, HorizontalGroup } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { StoreState, UserOrg } from 'app/types'; import { getUserOrganizations, setUserOrganization } from './state/actions'; diff --git a/public/app/features/org/UserInvitePage.tsx b/public/app/features/org/UserInvitePage.tsx index 8afa065a0b7..097ead15a32 100644 --- a/public/app/features/org/UserInvitePage.tsx +++ b/public/app/features/org/UserInvitePage.tsx @@ -2,7 +2,7 @@ import React, { FC } from 'react'; import { connect } from 'react-redux'; import { NavModel } from '@grafana/data'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; import { getNavModel } from 'app/core/selectors/navModel'; import { StoreState } from 'app/types/store'; diff --git a/public/app/features/playlist/PlaylistEditPage.tsx b/public/app/features/playlist/PlaylistEditPage.tsx index c98ba7ee00d..24eb413ef12 100644 --- a/public/app/features/playlist/PlaylistEditPage.tsx +++ b/public/app/features/playlist/PlaylistEditPage.tsx @@ -4,7 +4,7 @@ import { connect, MapStateToProps } from 'react-redux'; import { NavModel } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { getNavModel } from 'app/core/selectors/navModel'; import { StoreState } from 'app/types'; diff --git a/public/app/features/playlist/PlaylistNewPage.tsx b/public/app/features/playlist/PlaylistNewPage.tsx index c08d288ec99..e7ef84960b8 100644 --- a/public/app/features/playlist/PlaylistNewPage.tsx +++ b/public/app/features/playlist/PlaylistNewPage.tsx @@ -4,7 +4,7 @@ import { connect, MapStateToProps } from 'react-redux'; import { NavModel } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { getNavModel } from 'app/core/selectors/navModel'; import { StoreState } from 'app/types'; diff --git a/public/app/features/playlist/PlaylistPage.tsx b/public/app/features/playlist/PlaylistPage.tsx index 62e85085d2f..e8a997f0232 100644 --- a/public/app/features/playlist/PlaylistPage.tsx +++ b/public/app/features/playlist/PlaylistPage.tsx @@ -4,7 +4,7 @@ import { useDebounce } from 'react-use'; import { NavModel } from '@grafana/data'; import { ConfirmModal } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import PageActionBar from 'app/core/components/PageActionBar/PageActionBar'; import { getNavModel } from 'app/core/selectors/navModel'; import { contextSrv } from 'app/core/services/context_srv'; diff --git a/public/app/features/plugins/admin/routes.ts b/public/app/features/plugins/admin/routes.ts index 02a54c1c6c8..53a573ccbf4 100644 --- a/public/app/features/plugins/admin/routes.ts +++ b/public/app/features/plugins/admin/routes.ts @@ -7,16 +7,19 @@ import { PluginAdminRoutes } from './types'; const DEFAULT_ROUTES = [ { path: '/plugins', + navId: 'plugins', routeName: PluginAdminRoutes.Home, component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './pages/Browse')), }, { path: '/plugins/browse', + navId: 'plugins', routeName: PluginAdminRoutes.Browse, component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './pages/Browse')), }, { path: '/plugins/:pluginId/', + navId: 'plugins', routeName: PluginAdminRoutes.Details, component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './pages/PluginDetails')), }, @@ -25,16 +28,19 @@ const DEFAULT_ROUTES = [ const ADMIN_ROUTES = [ { path: '/admin/plugins', + navId: 'admin-plugins', routeName: PluginAdminRoutes.HomeAdmin, component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './pages/Browse')), }, { path: '/admin/plugins/browse', + navId: 'admin-plugins', routeName: PluginAdminRoutes.BrowseAdmin, component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './pages/Browse')), }, { path: '/admin/plugins/:pluginId/', + navId: 'admin-plugins', routeName: PluginAdminRoutes.DetailsAdmin, component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './pages/PluginDetails')), }, diff --git a/public/app/features/plugins/components/AppPluginLoader.tsx b/public/app/features/plugins/components/AppPluginLoader.tsx index fe263276930..37516513f1a 100644 --- a/public/app/features/plugins/components/AppPluginLoader.tsx +++ b/public/app/features/plugins/components/AppPluginLoader.tsx @@ -3,7 +3,7 @@ import { useLocation, useParams } from 'react-router-dom'; import { NavModel } from '@grafana/data'; import { getWarningNav } from 'app/angular/services/nav_model_srv'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { useImportAppPlugin } from '../hooks/useImportAppPlugin'; @@ -23,13 +23,13 @@ export const AppPluginLoader = ({ id, basePath }: AppPluginLoaderProps) => { const { pathname } = useLocation(); if (error) { - return ; + return ; } return ( <> {loading && } - {nav && } + {nav && } {!loading && plugin && plugin.root && ( { if (publicDashboardAccessToken) { - return new PublicDashboardDataSource(); + return new PublicDashboardDataSource(datasource); } if (datasource && (datasource as any).query) { diff --git a/public/app/features/sandbox/TestStuffPage.tsx b/public/app/features/sandbox/TestStuffPage.tsx index 7d34b5a9e4e..b07b6713842 100644 --- a/public/app/features/sandbox/TestStuffPage.tsx +++ b/public/app/features/sandbox/TestStuffPage.tsx @@ -11,11 +11,11 @@ import { PanelData, } from '@grafana/data'; import { Button, Table } from '@grafana/ui'; +import { Page } from 'app/core/components/Page/Page'; import { config } from 'app/core/config'; import { useAppNotification } from 'app/core/copy/appNotification'; import { QueryGroupOptions } from 'app/types'; -import Page from '../../core/components/Page/Page'; import { PanelRenderer } from '../panel/components/PanelRenderer'; import { QueryGroup } from '../query/components/QueryGroup'; import { PanelQueryRunner } from '../query/state/PanelQueryRunner'; diff --git a/public/app/features/scenes/SceneListPage.tsx b/public/app/features/scenes/SceneListPage.tsx new file mode 100644 index 00000000000..0ed740640f0 --- /dev/null +++ b/public/app/features/scenes/SceneListPage.tsx @@ -0,0 +1,31 @@ +// Libraries +import React, { FC } from 'react'; + +import { Stack } from '@grafana/experimental'; +import { Card } from '@grafana/ui'; +import { Page } from 'app/core/components/Page/Page'; + +// Types +import { getScenes } from './scenes'; + +export interface Props {} + +export const SceneListPage: FC = ({}) => { + const scenes = getScenes(); + + return ( + + + + {scenes.map((scene) => ( + + {scene.state.title} + + ))} + + + + ); +}; + +export default SceneListPage; diff --git a/public/app/features/scenes/ScenePage.tsx b/public/app/features/scenes/ScenePage.tsx new file mode 100644 index 00000000000..14cf1133635 --- /dev/null +++ b/public/app/features/scenes/ScenePage.tsx @@ -0,0 +1,24 @@ +// Libraries +import React, { FC } from 'react'; + +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; + +import { getSceneByTitle } from './scenes'; + +export interface Props extends GrafanaRouteComponentProps<{ name: string }> {} + +export const ScenePage: FC = (props) => { + const scene = getSceneByTitle(props.match.params.name); + + if (!scene) { + return

    Scene not found

    ; + } + + return ( +
    + +
    + ); +}; + +export default ScenePage; diff --git a/public/app/features/scenes/components/Scene.test.tsx b/public/app/features/scenes/components/Scene.test.tsx new file mode 100644 index 00000000000..6c616ff70f6 --- /dev/null +++ b/public/app/features/scenes/components/Scene.test.tsx @@ -0,0 +1,15 @@ +import { Scene } from './Scene'; +import { SceneFlexLayout } from './SceneFlexLayout'; + +describe('Scene', () => { + it('Simple scene', () => { + const scene = new Scene({ + title: 'Hello', + layout: new SceneFlexLayout({ + children: [], + }), + }); + + expect(scene.state.title).toBe('Hello'); + }); +}); diff --git a/public/app/features/scenes/components/Scene.tsx b/public/app/features/scenes/components/Scene.tsx new file mode 100644 index 00000000000..7bf37c758b5 --- /dev/null +++ b/public/app/features/scenes/components/Scene.tsx @@ -0,0 +1,56 @@ +import React from 'react'; + +import { PageToolbar, ToolbarButton } from '@grafana/ui'; + +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { SceneComponentProps, SceneObjectState, SceneObject } from '../core/types'; +import { UrlSyncManager } from '../services/UrlSyncManager'; + +interface SceneState extends SceneObjectState { + title: string; + layout: SceneObject; + actions?: SceneObject[]; + isEditing?: boolean; +} + +export class Scene extends SceneObjectBase { + static Component = SceneRenderer; + urlSyncManager?: UrlSyncManager; + + onMount() { + super.onMount(); + this.urlSyncManager = new UrlSyncManager(this); + } + + onUnmount() { + super.onUnmount(); + this.urlSyncManager!.cleanUp(); + } +} + +function SceneRenderer({ model }: SceneComponentProps) { + const { title, layout, actions = [], isEditing, $editor } = model.useState(); + + console.log('render scene'); + + return ( +
    + + {actions.map((action) => ( + + ))} + {$editor && ( + model.setState({ isEditing: !model.state.isEditing })} + /> + )} + +
    + + {$editor && <$editor.Component model={$editor} isEditing={isEditing} />} +
    +
    + ); +} diff --git a/public/app/features/scenes/components/SceneCanvasText.tsx b/public/app/features/scenes/components/SceneCanvasText.tsx new file mode 100644 index 00000000000..042c86312e7 --- /dev/null +++ b/public/app/features/scenes/components/SceneCanvasText.tsx @@ -0,0 +1,44 @@ +import React, { CSSProperties } from 'react'; + +import { Field, Input } from '@grafana/ui'; + +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { SceneComponentProps, SceneObjectState } from '../core/types'; + +export interface SceneCanvasTextState extends SceneObjectState { + text: string; + fontSize?: number; + align?: 'left' | 'center' | 'right'; +} + +export class SceneCanvasText extends SceneObjectBase { + static Editor = Editor; + static Component = ({ model }: SceneComponentProps) => { + const { text, fontSize = 20, align = 'left' } = model.useState(); + + const style: CSSProperties = { + fontSize: fontSize, + display: 'flex', + flexGrow: 1, + alignItems: 'center', + padding: 16, + justifyContent: align, + }; + + return
    {text}
    ; + }; +} + +function Editor({ model }: SceneComponentProps) { + const { fontSize } = model.useState(); + + return ( + + model.setState({ fontSize: parseInt(evt.currentTarget.value, 10) })} + /> + + ); +} diff --git a/public/app/features/scenes/components/SceneFlexLayout.tsx b/public/app/features/scenes/components/SceneFlexLayout.tsx new file mode 100644 index 00000000000..24691000c33 --- /dev/null +++ b/public/app/features/scenes/components/SceneFlexLayout.tsx @@ -0,0 +1,110 @@ +import React, { CSSProperties } from 'react'; + +import { Field, RadioButtonGroup } from '@grafana/ui'; + +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { SceneObject, SceneObjectSize, SceneObjectState, SceneLayoutState, SceneComponentProps } from '../core/types'; + +export type FlexLayoutDirection = 'column' | 'row'; + +interface SceneFlexLayoutState extends SceneObjectState, SceneLayoutState { + direction?: FlexLayoutDirection; +} + +export class SceneFlexLayout extends SceneObjectBase { + static Component = FlexLayoutRenderer; + static Editor = FlexLayoutEditor; + + toggleDirection() { + this.setState({ + direction: this.state.direction === 'row' ? 'column' : 'row', + }); + } +} + +function FlexLayoutRenderer({ model, isEditing }: SceneComponentProps) { + const { direction = 'row', children } = model.useState(); + + return ( +
    + {children.map((item) => ( + + ))} +
    + ); +} + +function FlexLayoutChildComponent({ + item, + direction, + isEditing, +}: { + item: SceneObject; + direction: FlexLayoutDirection; + isEditing?: boolean; +}) { + const { size } = item.useState(); + + return ( +
    + +
    + ); +} + +function getItemStyles(direction: FlexLayoutDirection, sizing: SceneObjectSize = {}) { + const { xSizing = 'fill', ySizing = 'fill' } = sizing; + + const style: CSSProperties = { + display: 'flex', + flexDirection: direction, + minWidth: sizing.minWidth, + minHeight: sizing.minHeight, + }; + + if (direction === 'column') { + if (sizing.height) { + style.height = sizing.height; + } else { + style.flexGrow = ySizing === 'fill' ? 1 : 0; + } + + if (sizing.width) { + style.width = sizing.width; + } else { + style.alignSelf = xSizing === 'fill' ? 'stretch' : 'flex-start'; + } + } else { + if (sizing.height) { + style.height = sizing.height; + } else { + style.alignSelf = ySizing === 'fill' ? 'stretch' : 'flex-start'; + } + + if (sizing.width) { + style.width = sizing.width; + } else { + style.flexGrow = xSizing === 'fill' ? 1 : 0; + } + } + + return style; +} + +function FlexLayoutEditor({ model }: SceneComponentProps) { + const { direction = 'row' } = model.useState(); + const options = [ + { icon: 'arrow-right', value: 'row' }, + { icon: 'arrow-down', value: 'column' }, + ]; + + return ( + + model.setState({ direction: value as FlexLayoutDirection })} + /> + + ); +} diff --git a/public/app/features/scenes/components/ScenePanelRepeater.tsx b/public/app/features/scenes/components/ScenePanelRepeater.tsx new file mode 100644 index 00000000000..6d2ba7569e6 --- /dev/null +++ b/public/app/features/scenes/components/ScenePanelRepeater.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import { LoadingState, PanelData } from '@grafana/data'; + +import { SceneDataNode } from '../core/SceneDataNode'; +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { SceneComponentProps, SceneObject, SceneObjectList, SceneObjectState, SceneLayoutState } from '../core/types'; + +interface RepeatOptions extends SceneObjectState { + layout: SceneObject; +} + +export class ScenePanelRepeater extends SceneObjectBase { + onMount() { + super.onMount(); + + this.subs.add( + this.getData().subscribe({ + next: (data) => { + if (data.data?.state === LoadingState.Done) { + this.performRepeat(data.data); + } + }, + }) + ); + } + + performRepeat(data: PanelData) { + // assume parent is a layout + const firstChild = this.state.layout.state.children[0]!; + const newChildren: SceneObjectList = []; + + for (const series of data.series) { + const clone = firstChild.clone({ + key: `${newChildren.length}`, + $data: new SceneDataNode({ + data: { + ...data, + series: [series], + }, + }), + }); + + newChildren.push(clone); + } + + this.state.layout.setState({ children: newChildren }); + } + + static Component = ({ model, isEditing }: SceneComponentProps) => { + const { layout } = model.useState(); + return ; + }; +} diff --git a/public/app/features/scenes/components/SceneTimePicker.tsx b/public/app/features/scenes/components/SceneTimePicker.tsx new file mode 100644 index 00000000000..5ef53b264c5 --- /dev/null +++ b/public/app/features/scenes/components/SceneTimePicker.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { RefreshPicker, ToolbarButtonRow } from '@grafana/ui'; +import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePickerWithHistory'; + +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { SceneComponentProps, SceneObjectState } from '../core/types'; + +export interface SceneTimePickerState extends SceneObjectState { + hidePicker?: boolean; +} + +export class SceneTimePicker extends SceneObjectBase { + static Component = SceneTimePickerRenderer; +} + +function SceneTimePickerRenderer({ model }: SceneComponentProps) { + const { hidePicker } = model.useState(); + const timeRange = model.getTimeRange(); + const timeRangeState = timeRange.useState(); + + if (hidePicker) { + return null; + } + + return ( + + {}} + onMoveForward={() => {}} + onZoom={() => {}} + onChangeTimeZone={() => {}} + onChangeFiscalYearStartMonth={() => {}} + /> + + + + ); +} diff --git a/public/app/features/scenes/components/SceneToolbarButton.tsx b/public/app/features/scenes/components/SceneToolbarButton.tsx new file mode 100644 index 00000000000..a7c588a0e0e --- /dev/null +++ b/public/app/features/scenes/components/SceneToolbarButton.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import { IconName, Input, ToolbarButton } from '@grafana/ui'; + +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { SceneComponentProps, SceneObjectState } from '../core/types'; + +export interface ToolbarButtonState extends SceneObjectState { + icon: IconName; + onClick: () => void; +} + +export class SceneToolbarButton extends SceneObjectBase { + static Component = ({ model }: SceneComponentProps) => { + const state = model.useState(); + + return ; + }; +} + +export interface SceneToolbarInputState extends SceneObjectState { + value?: string; + onChange: (value: number) => void; +} + +export class SceneToolbarInput extends SceneObjectBase { + static Component = ({ model }: SceneComponentProps) => { + const state = model.useState(); + + return ( + { + model.state.onChange(parseInt(evt.currentTarget.value, 10)); + }} + /> + ); + }; +} diff --git a/public/app/features/scenes/components/VizPanel.tsx b/public/app/features/scenes/components/VizPanel.tsx new file mode 100644 index 00000000000..dee9ef6865d --- /dev/null +++ b/public/app/features/scenes/components/VizPanel.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import AutoSizer from 'react-virtualized-auto-sizer'; + +import { AbsoluteTimeRange, FieldConfigSource, toUtc } from '@grafana/data'; +import { PanelRenderer } from '@grafana/runtime'; +import { Field, PanelChrome, Input } from '@grafana/ui'; + +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { SceneComponentProps, SceneObjectState } from '../core/types'; + +export interface VizPanelState extends SceneObjectState { + title?: string; + pluginId: string; + options?: object; + fieldConfig?: FieldConfigSource; +} + +export class VizPanel extends SceneObjectBase { + static Component = ScenePanelRenderer; + static Editor = VizPanelEditor; + + onSetTimeRange = (timeRange: AbsoluteTimeRange) => { + const sceneTimeRange = this.getTimeRange(); + sceneTimeRange.setState({ + raw: { + from: toUtc(timeRange.from), + to: toUtc(timeRange.to), + }, + from: toUtc(timeRange.from), + to: toUtc(timeRange.to), + }); + }; +} + +function ScenePanelRenderer({ model }: SceneComponentProps) { + const { title, pluginId, options, fieldConfig } = model.useState(); + const { data } = model.getData().useState(); + + return ( + + {({ width, height }) => { + if (width < 3 || height < 3) { + return null; + } + + return ( + + {(innerWidth, innerHeight) => ( + <> + {}} + onChangeTimeRange={model.onSetTimeRange} + /> + + )} + + ); + }} + + ); +} + +ScenePanelRenderer.displayName = 'ScenePanelRenderer'; + +function VizPanelEditor({ model }: SceneComponentProps) { + const { title } = model.useState(); + + return ( + + model.setState({ title: evt.currentTarget.value })} /> + + ); +} diff --git a/public/app/features/scenes/core/SceneComponentEditWrapper.tsx b/public/app/features/scenes/core/SceneComponentEditWrapper.tsx new file mode 100644 index 00000000000..9bbde1c2511 --- /dev/null +++ b/public/app/features/scenes/core/SceneComponentEditWrapper.tsx @@ -0,0 +1,82 @@ +import { css } from '@emotion/css'; +import React, { CSSProperties } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +import { SceneObjectBase } from './SceneObjectBase'; +import { SceneComponentProps } from './types'; + +export function SceneComponentEditWrapper>({ + model, + isEditing, +}: SceneComponentProps) { + const Component = (model as any).constructor['Component'] ?? EmptyRenderer; + const inner = ; + + model.useMount(); + + if (!isEditing) { + return inner; + } + + return {inner}; +} + +export function SceneComponentEditingWrapper>({ + model, + children, +}: { + model: T; + children: React.ReactNode; +}) { + const styles = useStyles2(getStyles); + const editor = model.getSceneEditor(); + const { hoverObject, selectedObject } = editor.useState(); + + const onMouseEnter = () => editor.onMouseEnterObject(model); + const onMouseLeave = () => editor.onMouseLeaveObject(model); + + const onClick = (evt: React.MouseEvent) => { + evt.stopPropagation(); + editor.onSelectObject(model); + }; + + const style: CSSProperties = {}; + let className = styles.wrapper; + + if (hoverObject?.ref === model) { + className += ' ' + styles.hover; + } + if (selectedObject?.ref === model) { + className += ' ' + styles.selected; + } + + return ( +
    + {children} +
    + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + wrapper: css({ + display: 'flex', + flexGrow: 1, + padding: 8, + border: `1px dashed ${theme.colors.primary.main}`, + cursor: 'pointer', + }), + hover: css({ + border: `1px solid ${theme.colors.primary.border}`, + }), + selected: css({ + border: `1px solid ${theme.colors.error.border}`, + }), + }; +}; + +function EmptyRenderer(_: SceneComponentProps): React.ReactElement | null { + return null; +} diff --git a/public/app/features/scenes/core/SceneDataNode.ts b/public/app/features/scenes/core/SceneDataNode.ts new file mode 100644 index 00000000000..adaf6b256e4 --- /dev/null +++ b/public/app/features/scenes/core/SceneDataNode.ts @@ -0,0 +1,10 @@ +import { PanelData } from '@grafana/data'; + +import { SceneObjectBase } from './SceneObjectBase'; +import { SceneObjectState } from './types'; + +export interface SceneDataNodeState extends SceneObjectState { + data?: PanelData; +} + +export class SceneDataNode extends SceneObjectBase {} diff --git a/public/app/features/scenes/core/SceneObjectBase.test.ts b/public/app/features/scenes/core/SceneObjectBase.test.ts new file mode 100644 index 00000000000..2ac12132c81 --- /dev/null +++ b/public/app/features/scenes/core/SceneObjectBase.test.ts @@ -0,0 +1,68 @@ +import { SceneObjectBase } from './SceneObjectBase'; +import { SceneObject, SceneObjectList, SceneObjectState } from './types'; + +interface TestSceneState extends SceneObjectState { + name?: string; + nested?: SceneObject; + children?: SceneObjectList; + actions?: SceneObjectList; +} + +class TestScene extends SceneObjectBase {} + +describe('SceneObject', () => { + it('Can clone', () => { + const scene = new TestScene({ + nested: new TestScene({ + name: 'nested', + }), + children: [ + new TestScene({ + name: 'layout child', + }), + ], + }); + + scene.state.nested?.onMount(); + + const clone = scene.clone(); + expect(clone).not.toBe(scene); + expect(clone.state.nested).not.toBe(scene.state.nested); + expect(clone.state.nested?.isMounted).toBe(undefined); + expect(clone.state.children![0]).not.toBe(scene.state.children![0]); + }); + + it('SceneObject should have parent when added to container', () => { + const scene = new TestScene({ + nested: new TestScene({ + name: 'nested', + }), + children: [ + new TestScene({ + name: 'layout child', + }), + ], + actions: [ + new TestScene({ + name: 'layout child', + }), + ], + }); + + expect(scene.parent).toBe(undefined); + expect(scene.state.nested?.parent).toBe(scene); + expect(scene.state.children![0].parent).toBe(scene); + expect(scene.state.actions![0].parent).toBe(scene); + }); + + it('Can clone with state change', () => { + const scene = new TestScene({ + nested: new TestScene({ + name: 'nested', + }), + }); + + const clone = scene.clone({ name: 'new name' }); + expect(clone.state.name).toBe('new name'); + }); +}); diff --git a/public/app/features/scenes/core/SceneObjectBase.tsx b/public/app/features/scenes/core/SceneObjectBase.tsx new file mode 100644 index 00000000000..89c08b11bdd --- /dev/null +++ b/public/app/features/scenes/core/SceneObjectBase.tsx @@ -0,0 +1,220 @@ +import { useEffect } from 'react'; +import { useObservable } from 'react-use'; +import { Observer, Subject, Subscription } from 'rxjs'; +import { v4 as uuidv4 } from 'uuid'; + +import { EventBusSrv } from '@grafana/data'; + +import { SceneComponentEditWrapper } from './SceneComponentEditWrapper'; +import { SceneObjectStateChangedEvent } from './events'; +import { + SceneDataState, + SceneObject, + SceneLayoutState, + SceneObjectState, + SceneComponent, + SceneEditor, + SceneObjectList, + SceneTimeRange, +} from './types'; + +export abstract class SceneObjectBase implements SceneObject { + subject = new Subject(); + state: TState; + parent?: SceneObjectBase; + subs = new Subscription(); + isMounted?: boolean; + events = new EventBusSrv(); + + constructor(state: TState) { + if (!state.key) { + state.key = uuidv4(); + } + + this.state = state; + this.subject.next(state); + this.setParent(); + } + + /** + * Used in render functions when rendering a SceneObject. + * Wraps the component in an EditWrapper that handles edit mode + */ + get Component(): SceneComponent { + return SceneComponentEditWrapper; + } + + /** + * Temporary solution, should be replaced by declarative options + */ + get Editor(): SceneComponent { + return ((this as any).constructor['Editor'] ?? (() => null)) as SceneComponent; + } + + private setParent() { + for (const propValue of Object.values(this.state)) { + if (propValue instanceof SceneObjectBase) { + propValue.parent = this; + } + + if (Array.isArray(propValue)) { + for (const child of propValue) { + if (child instanceof SceneObjectBase) { + child.parent = this; + } + } + } + } + } + + /** This function implements the Subscribable interface */ + subscribe(observer: Partial>) { + return this.subject.subscribe(observer); + } + + setState(update: Partial) { + const prevState = this.state; + this.state = { + ...this.state, + ...update, + }; + this.setParent(); + this.subject.next(this.state); + + // broadcast state change. This is event is subscribed to by UrlSyncManager and UndoManager + this.getRoot().events.publish( + new SceneObjectStateChangedEvent({ + prevState, + newState: this.state, + partialUpdate: update, + changedObject: this, + }) + ); + } + + private getRoot(): SceneObject { + return !this.parent ? this : this.parent.getRoot(); + } + + onMount() { + this.isMounted = true; + + const { $data } = this.state; + if ($data && !$data.isMounted) { + $data.onMount(); + } + } + + onUnmount() { + this.isMounted = false; + + const { $data } = this.state; + if ($data && $data.isMounted) { + $data.onUnmount(); + } + + this.subs.unsubscribe(); + this.subs = new Subscription(); + } + + /** + * The scene object needs to know when the react component is mounted to trigger query and other lazy actions + */ + useMount() { + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (!this.isMounted) { + this.onMount(); + } + return () => { + if (this.isMounted) { + this.onUnmount(); + } + }; + }, []); + + return this; + } + + useState() { + // eslint-disable-next-line react-hooks/rules-of-hooks + return useObservable(this.subject, this.state); + } + + /** + * Will walk up the scene object graph to the closest $timeRange scene object + */ + getTimeRange(): SceneTimeRange { + const { $timeRange } = this.state; + if ($timeRange) { + return $timeRange; + } + + if (this.parent) { + return this.parent.getTimeRange(); + } + + throw new Error('No time range found in scene tree'); + } + + /** + * Will walk up the scene object graph to the closest $data scene object + */ + getData(): SceneObject { + const { $data } = this.state; + if ($data) { + return $data; + } + + if (this.parent) { + return this.parent.getData(); + } + + throw new Error('No data found in scene tree'); + } + + /** + * Will walk up the scene object graph to the closest $editor scene object + */ + getSceneEditor(): SceneEditor { + const { $editor } = this.state; + if ($editor) { + return $editor; + } + + if (this.parent) { + return this.parent.getSceneEditor(); + } + + throw new Error('No editor found in scene tree'); + } + + /** + * Will create new SceneItem with shalled cloned state, but all states items of type SceneItem are deep cloned + */ + clone(withState?: Partial): this { + const clonedState = { ...this.state }; + + // Clone any SceneItems in state + for (const key in clonedState) { + const propValue = clonedState[key]; + if (propValue instanceof SceneObjectBase) { + clonedState[key] = propValue.clone(); + } + } + + // Clone layout children + const layout = this.state as any as SceneLayoutState; + if (layout.children) { + const newChildren: SceneObjectList = []; + for (const child of layout.children) { + newChildren.push(child.clone()); + } + (clonedState as any).children = newChildren; + } + + Object.assign(clonedState, withState); + + return new (this.constructor as any)(clonedState); + } +} diff --git a/public/app/features/scenes/core/SceneTimeRange.tsx b/public/app/features/scenes/core/SceneTimeRange.tsx new file mode 100644 index 00000000000..595db0d864b --- /dev/null +++ b/public/app/features/scenes/core/SceneTimeRange.tsx @@ -0,0 +1,29 @@ +import { TimeRange, UrlQueryMap } from '@grafana/data'; + +import { SceneObjectBase } from './SceneObjectBase'; +import { SceneObjectWithUrlSync, SceneTimeRangeState } from './types'; + +export class SceneTimeRange extends SceneObjectBase implements SceneObjectWithUrlSync { + onTimeRangeChange = (timeRange: TimeRange) => { + this.setState(timeRange); + }; + + onRefresh = () => { + // TODO re-eval time range + this.setState({ ...this.state }); + }; + + onIntervalChanged = (_: string) => {}; + + /** These url sync functions are only placeholders for something more sophisticated */ + getUrlState() { + return { + from: this.state.raw.from, + to: this.state.raw.to, + } as any; + } + + updateFromUrl(values: UrlQueryMap) { + // TODO + } +} diff --git a/public/app/features/scenes/core/events.ts b/public/app/features/scenes/core/events.ts new file mode 100644 index 00000000000..a0113c03311 --- /dev/null +++ b/public/app/features/scenes/core/events.ts @@ -0,0 +1,14 @@ +import { BusEventWithPayload } from '@grafana/data'; + +import { SceneObject } from './types'; + +export interface SceneObjectStateChangedPayload { + prevState: any; + newState: any; + partialUpdate: any; + changedObject: SceneObject; +} + +export class SceneObjectStateChangedEvent extends BusEventWithPayload { + static type = 'scene-object-state-change'; +} diff --git a/public/app/features/scenes/core/types.ts b/public/app/features/scenes/core/types.ts new file mode 100644 index 00000000000..f44bfee7b21 --- /dev/null +++ b/public/app/features/scenes/core/types.ts @@ -0,0 +1,122 @@ +import React from 'react'; +import { Subscribable } from 'rxjs'; + +import { EventBus, PanelData, TimeRange, UrlQueryMap } from '@grafana/data'; + +import { SceneVariableSet } from '../variables/types'; + +export interface SceneObjectState { + key?: string; + size?: SceneObjectSize; + $timeRange?: SceneTimeRange; + $data?: SceneObject; + $editor?: SceneEditor; + $variables?: SceneVariableSet; +} + +export interface SceneObjectSize { + width?: number | string; + height?: number | string; + xSizing?: 'fill' | 'content'; + ySizing?: 'fill' | 'content'; + x?: number; + y?: number; + minWidth?: number | string; + minHeight?: number | string; +} + +export interface SceneComponentProps { + model: T; + isEditing?: boolean; +} + +export type SceneComponent = React.FunctionComponent>; + +export interface SceneDataState extends SceneObjectState { + data?: PanelData; +} + +export interface SceneObject extends Subscribable { + /** The current state */ + state: TState; + + /** True when there is a React component mounted for this Object */ + isMounted?: boolean; + + /** SceneObject parent */ + parent?: SceneObject; + + /** Currently only used from root to broadcast events */ + events: EventBus; + + /** Utility hook that wraps useObservable. Used by React components to subscribes to state changes */ + useState(): TState; + + /** How to modify state */ + setState(state: Partial): void; + + /** Utility hook for main component so that object knows when it's mounted */ + useMount(): this; + + /** Called when component mounts. A place to register event listeners add subscribe to state changes */ + onMount(): void; + + /** Called when component unmounts. Unsubscribe to events */ + onUnmount(): void; + + /** Get the scene editor */ + getSceneEditor(): SceneEditor; + + /** Returns a deep clone this object and all it's children */ + clone(state?: Partial): this; + + /** A React component to use for rendering the object */ + Component(props: SceneComponentProps>): React.ReactElement | null; + + /** To be replaced by declarative method */ + Editor(props: SceneComponentProps>): React.ReactElement | null; +} + +export type SceneObjectList = Array>; + +export interface SceneLayoutState extends SceneObjectState { + children: SceneObjectList; +} + +export type SceneLayout = SceneObject; + +export interface SceneEditorState extends SceneObjectState { + hoverObject?: SceneObjectRef; + selectedObject?: SceneObjectRef; +} + +export interface SceneEditor extends SceneObject { + onMouseEnterObject(model: SceneObject): void; + onMouseLeaveObject(model: SceneObject): void; + onSelectObject(model: SceneObject): void; +} + +export interface SceneTimeRangeState extends SceneObjectState, TimeRange {} +export interface SceneTimeRange extends SceneObject { + onTimeRangeChange(timeRange: TimeRange): void; + onIntervalChanged(interval: string): void; + onRefresh(): void; +} + +export interface SceneObjectRef { + ref: SceneObject; +} + +export function isSceneObject(obj: any): obj is SceneObject { + return obj.useState !== undefined; +} + +/** These functions are still just temporary until this get's refined */ +export interface SceneObjectWithUrlSync extends SceneObject { + getUrlState(): UrlQueryMap; + updateFromUrl(values: UrlQueryMap): void; +} + +export function isSceneObjectWithUrlSync(obj: any): obj is SceneObjectWithUrlSync { + return obj.getUrlState !== undefined; +} diff --git a/public/app/features/scenes/editor/SceneEditManager.tsx b/public/app/features/scenes/editor/SceneEditManager.tsx new file mode 100644 index 00000000000..bfa22349e71 --- /dev/null +++ b/public/app/features/scenes/editor/SceneEditManager.tsx @@ -0,0 +1,70 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; + +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { SceneEditorState, SceneEditor, SceneObject, SceneComponentProps, SceneComponent } from '../core/types'; + +import { SceneObjectEditor } from './SceneObjectEditor'; +import { SceneObjectTree } from './SceneObjectTree'; + +export class SceneEditManager extends SceneObjectBase implements SceneEditor { + static Component = SceneEditorRenderer; + + get Component(): SceneComponent { + return SceneEditorRenderer; + } + + onMouseEnterObject(model: SceneObject) { + this.setState({ hoverObject: { ref: model } }); + } + + onMouseLeaveObject(model: SceneObject) { + if (model.parent) { + this.setState({ hoverObject: { ref: model.parent } }); + } else { + this.setState({ hoverObject: undefined }); + } + } + + onSelectObject(model: SceneObject) { + this.setState({ selectedObject: { ref: model } }); + } +} + +function SceneEditorRenderer({ model, isEditing }: SceneComponentProps) { + const { selectedObject } = model.useState(); + const styles = useStyles2(getStyles); + + if (!isEditing) { + return null; + } + + return ( +
    +
    + +
    + {selectedObject && } +
    + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + display: 'flex', + flexGrow: 0, + border: `1px solid ${theme.colors.border.weak}`, + background: theme.colors.background.primary, + width: theme.spacing(40), + cursor: 'pointer', + flexDirection: 'column', + }), + tree: css({ + padding: theme.spacing(0.25, 1), + }), + }; +}; diff --git a/public/app/features/scenes/editor/SceneObjectEditor.tsx b/public/app/features/scenes/editor/SceneObjectEditor.tsx new file mode 100644 index 00000000000..269303bd9a7 --- /dev/null +++ b/public/app/features/scenes/editor/SceneObjectEditor.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { OptionsPaneCategory } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategory'; + +import { SceneObject } from '../core/types'; + +export interface Props { + model: SceneObject; +} + +export function SceneObjectEditor({ model }: Props) { + return ( + + + + ); +} diff --git a/public/app/features/scenes/editor/SceneObjectTree.tsx b/public/app/features/scenes/editor/SceneObjectTree.tsx new file mode 100644 index 00000000000..13c33b42e80 --- /dev/null +++ b/public/app/features/scenes/editor/SceneObjectTree.tsx @@ -0,0 +1,81 @@ +import { css, cx } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Icon, useStyles2 } from '@grafana/ui'; + +import { SceneObject, SceneLayoutState, SceneObjectList, isSceneObject } from '../core/types'; + +export interface Props { + node: SceneObject; + selectedObject?: SceneObject; +} + +export function SceneObjectTree({ node, selectedObject }: Props) { + const styles = useStyles2(getStyles); + const state = node.useState(); + let children: SceneObjectList = []; + + for (const propKey of Object.keys(state)) { + const propValue = (state as any)[propKey]; + if (isSceneObject(propValue)) { + children.push(propValue); + } + } + + let layoutChildren = (state as SceneLayoutState).children; + if (layoutChildren) { + for (const child of layoutChildren) { + children.push(child); + } + } + + const name = node.constructor.name; + const isSelected = selectedObject === node; + const onSelectNode = () => node.getSceneEditor().onSelectObject(node); + + return ( +
    +
    +
    {children.length > 0 && }
    +
    {name}
    +
    + {children.length > 0 && ( +
    + {children.map((child) => ( + + ))} +
    + )} +
    + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + node: css({ + display: 'flex', + flexGrow: 0, + cursor: 'pointer', + flexDirection: 'column', + padding: '2px 4px', + }), + header: css({ + display: 'flex', + fontWeight: 500, + }), + name: css({}), + selected: css({ + color: theme.colors.error.text, + }), + icon: css({ + width: theme.spacing(3), + color: theme.colors.text.secondary, + }), + children: css({ + display: 'flex', + flexDirection: 'column', + paddingLeft: 8, + }), + }; +}; diff --git a/public/app/features/scenes/querying/SceneQueryRunner.ts b/public/app/features/scenes/querying/SceneQueryRunner.ts new file mode 100644 index 00000000000..fb76b1c5088 --- /dev/null +++ b/public/app/features/scenes/querying/SceneQueryRunner.ts @@ -0,0 +1,133 @@ +import { cloneDeep } from 'lodash'; +import { Unsubscribable } from 'rxjs'; + +import { + CoreApp, + DataQuery, + DataQueryRequest, + DataSourceApi, + DataSourceRef, + PanelData, + rangeUtil, + ScopedVars, + TimeRange, +} from '@grafana/data'; +import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; +import { getNextRequestId } from 'app/features/query/state/PanelQueryRunner'; +import { runRequest } from 'app/features/query/state/runRequest'; + +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { SceneObjectState } from '../core/types'; + +export interface QueryRunnerState extends SceneObjectState { + data?: PanelData; + queries: DataQueryExtended[]; +} + +export interface DataQueryExtended extends DataQuery { + [key: string]: any; +} + +export class SceneQueryRunner extends SceneObjectBase { + private querySub?: Unsubscribable; + + onMount() { + super.onMount(); + + const timeRange = this.getTimeRange(); + + this.subs.add( + timeRange.subscribe({ + next: (timeRange) => { + this.runWithTimeRange(timeRange); + }, + }) + ); + + if (!this.state.data) { + this.runQueries(); + } + } + + onUnmount() { + super.onUnmount(); + this.cleanUp(); + } + + cleanUp() { + if (this.querySub) { + this.querySub.unsubscribe(); + this.querySub = undefined; + } + } + + runQueries() { + const timeRange = this.getTimeRange(); + this.runWithTimeRange(timeRange.state); + } + + private async runWithTimeRange(timeRange: TimeRange) { + const queries = cloneDeep(this.state.queries); + + const request: DataQueryRequest = { + app: CoreApp.Dashboard, + requestId: getNextRequestId(), + timezone: 'browser', + panelId: 1, + dashboardId: 1, + range: timeRange, + interval: '1s', + intervalMs: 1000, + targets: cloneDeep(this.state.queries), + maxDataPoints: 500, + scopedVars: {}, + startTime: Date.now(), + }; + + try { + const ds = await getDataSource(queries[0].datasource!, request.scopedVars); + + // Attach the data source name to each query + request.targets = request.targets.map((query) => { + if (!query.datasource) { + query.datasource = ds.getRef(); + } + return query; + }); + + const lowerIntervalLimit = ds.interval; + const norm = rangeUtil.calculateInterval(timeRange, request.maxDataPoints ?? 1000, lowerIntervalLimit); + + // make shallow copy of scoped vars, + // and add built in variables interval and interval_ms + request.scopedVars = Object.assign({}, request.scopedVars, { + __interval: { text: norm.interval, value: norm.interval }, + __interval_ms: { text: norm.intervalMs.toString(), value: norm.intervalMs }, + }); + + request.interval = norm.interval; + request.intervalMs = norm.intervalMs; + + console.log('Query runner run'); + + this.querySub = runRequest(ds, request).subscribe({ + next: (data) => { + console.log('set data', data, data.state); + this.setState({ data }); + }, + }); + } catch (err) { + console.error('PanelQueryRunner Error', err); + } + } +} + +async function getDataSource( + datasource: DataSourceRef | string | DataSourceApi | null, + scopedVars: ScopedVars +): Promise { + if (datasource && (datasource as any).query) { + return datasource as DataSourceApi; + } + return await getDatasourceSrv().get(datasource as string, scopedVars); +} diff --git a/public/app/features/scenes/scenes/demo.tsx b/public/app/features/scenes/scenes/demo.tsx new file mode 100644 index 00000000000..fd46030217d --- /dev/null +++ b/public/app/features/scenes/scenes/demo.tsx @@ -0,0 +1,140 @@ +import { getDefaultTimeRange } from '@grafana/data'; + +import { Scene } from '../components/Scene'; +import { SceneCanvasText } from '../components/SceneCanvasText'; +import { SceneFlexLayout } from '../components/SceneFlexLayout'; +import { ScenePanelRepeater } from '../components/ScenePanelRepeater'; +import { SceneTimePicker } from '../components/SceneTimePicker'; +import { SceneToolbarInput } from '../components/SceneToolbarButton'; +import { VizPanel } from '../components/VizPanel'; +import { SceneTimeRange } from '../core/SceneTimeRange'; +import { SceneEditManager } from '../editor/SceneEditManager'; +import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +export function getFlexLayoutTest(): Scene { + const scene = new Scene({ + title: 'Flex layout test', + layout: new SceneFlexLayout({ + direction: 'row', + children: [ + new VizPanel({ + pluginId: 'timeseries', + title: 'Dynamic height and width', + size: { minWidth: '70%' }, + }), + new SceneFlexLayout({ + // size: { width: 450 }, + direction: 'column', + children: [ + new VizPanel({ + pluginId: 'timeseries', + title: 'Fill height', + }), + new VizPanel({ + pluginId: 'timeseries', + title: 'Fill height', + }), + new SceneCanvasText({ + text: 'Size to content', + fontSize: 20, + size: { ySizing: 'content' }, + align: 'center', + }), + new VizPanel({ + pluginId: 'timeseries', + title: 'Fixed height', + size: { height: 300 }, + }), + ], + }), + ], + }), + $editor: new SceneEditManager({}), + $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $data: new SceneQueryRunner({ + queries: [ + { + refId: 'A', + datasource: { + uid: 'gdev-testdata', + type: 'testdata', + }, + scenarioId: 'random_walk', + }, + ], + }), + actions: [new SceneTimePicker({})], + }); + + return scene; +} + +export function getScenePanelRepeaterTest(): Scene { + const queryRunner = new SceneQueryRunner({ + queries: [ + { + refId: 'A', + datasource: { + uid: 'gdev-testdata', + type: 'testdata', + }, + seriesCount: 5, + alias: '__server_names', + scenarioId: 'random_walk', + }, + ], + }); + + const scene = new Scene({ + title: 'Panel repeater test', + layout: new ScenePanelRepeater({ + layout: new SceneFlexLayout({ + direction: 'column', + children: [ + new SceneFlexLayout({ + size: { minHeight: 200 }, + children: [ + new VizPanel({ + pluginId: 'timeseries', + title: 'Title', + options: { + legend: { displayMode: 'hidden' }, + }, + }), + new VizPanel({ + size: { width: 300 }, + pluginId: 'stat', + fieldConfig: { defaults: { displayName: 'Last' }, overrides: [] }, + options: { + graphMode: 'none', + }, + }), + ], + }), + ], + }), + }), + $editor: new SceneEditManager({}), + $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $data: queryRunner, + actions: [ + new SceneToolbarInput({ + value: '5', + onChange: (newValue) => { + queryRunner.setState({ + queries: [ + { + ...queryRunner.state.queries[0], + seriesCount: newValue, + }, + ], + }); + queryRunner.runQueries(); + }, + }), + new SceneTimePicker({}), + ], + }); + + return scene; +} diff --git a/public/app/features/scenes/scenes/index.tsx b/public/app/features/scenes/scenes/index.tsx new file mode 100644 index 00000000000..150d6b2b77b --- /dev/null +++ b/public/app/features/scenes/scenes/index.tsx @@ -0,0 +1,23 @@ +import { Scene } from '../components/Scene'; + +import { getFlexLayoutTest, getScenePanelRepeaterTest } from './demo'; +import { getNestedScene } from './nested'; + +export function getScenes(): Scene[] { + return [getFlexLayoutTest(), getScenePanelRepeaterTest(), getNestedScene()]; +} + +const cache: Record = {}; + +export function getSceneByTitle(title: string) { + if (cache[title]) { + return cache[title]; + } + + const scene = getScenes().find((x) => x.state.title === title); + if (scene) { + cache[title] = scene; + } + + return scene; +} diff --git a/public/app/features/scenes/scenes/nested.tsx b/public/app/features/scenes/scenes/nested.tsx new file mode 100644 index 00000000000..64076f1ca4c --- /dev/null +++ b/public/app/features/scenes/scenes/nested.tsx @@ -0,0 +1,73 @@ +import { getDefaultTimeRange } from '@grafana/data'; + +import { Scene } from '../components/Scene'; +import { SceneFlexLayout } from '../components/SceneFlexLayout'; +import { SceneTimePicker } from '../components/SceneTimePicker'; +import { VizPanel } from '../components/VizPanel'; +import { SceneTimeRange } from '../core/SceneTimeRange'; +import { SceneQueryRunner } from '../querying/SceneQueryRunner'; + +export function getNestedScene(): Scene { + const scene = new Scene({ + title: 'Nested Scene demo', + layout: new SceneFlexLayout({ + direction: 'column', + children: [ + new VizPanel({ + key: '3', + pluginId: 'timeseries', + title: 'Panel 3', + }), + getInnerScene('Inner scene'), + ], + }), + $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $data: new SceneQueryRunner({ + queries: [ + { + refId: 'A', + datasource: { + uid: 'gdev-testdata', + type: 'testdata', + }, + scenarioId: 'random_walk', + }, + ], + }), + actions: [new SceneTimePicker({})], + }); + + return scene; +} + +export function getInnerScene(title: string): Scene { + const scene = new Scene({ + title: title, + layout: new SceneFlexLayout({ + direction: 'row', + children: [ + new VizPanel({ + key: '3', + pluginId: 'timeseries', + title: 'Data', + }), + ], + }), + $timeRange: new SceneTimeRange(getDefaultTimeRange()), + $data: new SceneQueryRunner({ + queries: [ + { + refId: 'A', + datasource: { + uid: 'gdev-testdata', + type: 'testdata', + }, + scenarioId: 'random_walk', + }, + ], + }), + actions: [new SceneTimePicker({})], + }); + + return scene; +} diff --git a/public/app/features/scenes/services/UrlSyncManager.ts b/public/app/features/scenes/services/UrlSyncManager.ts new file mode 100644 index 00000000000..b065d90ef49 --- /dev/null +++ b/public/app/features/scenes/services/UrlSyncManager.ts @@ -0,0 +1,36 @@ +import { Location } from 'history'; +import { Unsubscribable } from 'rxjs'; + +import { locationService } from '@grafana/runtime'; + +import { SceneObjectStateChangedEvent } from '../core/events'; +import { isSceneObjectWithUrlSync, SceneObject } from '../core/types'; + +export class UrlSyncManager { + private locationListenerUnsub: () => void; + private stateChangeSub: Unsubscribable; + + constructor(sceneRoot: SceneObject) { + this.stateChangeSub = sceneRoot.events.subscribe(SceneObjectStateChangedEvent, this.onStateChanged); + this.locationListenerUnsub = locationService.getHistory().listen(this.onLocationUpdate); + } + + onLocationUpdate = (location: Location) => { + // TODO: find any scene object whose state we need to update + }; + + onStateChanged = ({ payload }: SceneObjectStateChangedEvent) => { + const changedObject = payload.changedObject; + if (!isSceneObjectWithUrlSync(changedObject)) { + return; + } + + const urlUpdate = changedObject.getUrlState(); + locationService.partial(urlUpdate, true); + }; + + cleanUp() { + this.stateChangeSub.unsubscribe(); + this.locationListenerUnsub(); + } +} diff --git a/public/app/features/scenes/variables/SceneVariableSet.test.ts b/public/app/features/scenes/variables/SceneVariableSet.test.ts new file mode 100644 index 00000000000..230b49545d4 --- /dev/null +++ b/public/app/features/scenes/variables/SceneVariableSet.test.ts @@ -0,0 +1,43 @@ +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { SceneObjectState } from '../core/types'; + +import { sceneTemplateInterpolator, SceneVariableManager, TextBoxSceneVariable } from './SceneVariableSet'; + +interface TestSceneState extends SceneObjectState { + nested?: TestScene; +} + +class TestScene extends SceneObjectBase {} + +describe('SceneObject with variables', () => { + it('Should be interpolate and use closest variable', () => { + const scene = new TestScene({ + $variables: new SceneVariableManager({ + variables: [ + new TextBoxSceneVariable({ + name: 'test', + current: { value: 'hello' }, + }), + new TextBoxSceneVariable({ + name: 'atRootOnly', + current: { value: 'RootValue' }, + }), + ], + }), + nested: new TestScene({ + $variables: new SceneVariableManager({ + variables: [ + new TextBoxSceneVariable({ + name: 'test', + current: { value: 'nestedValue' }, + }), + ], + }), + }), + }); + + expect(sceneTemplateInterpolator('${test}', scene)).toBe('hello'); + expect(sceneTemplateInterpolator('${test}', scene.state.nested!)).toBe('nestedValue'); + expect(sceneTemplateInterpolator('${atRootOnly}', scene.state.nested!)).toBe('RootValue'); + }); +}); diff --git a/public/app/features/scenes/variables/SceneVariableSet.ts b/public/app/features/scenes/variables/SceneVariableSet.ts new file mode 100644 index 00000000000..13e0c21862a --- /dev/null +++ b/public/app/features/scenes/variables/SceneVariableSet.ts @@ -0,0 +1,50 @@ +import { variableRegex } from 'app/features/variables/utils'; + +import { SceneObjectBase } from '../core/SceneObjectBase'; +import { SceneObject } from '../core/types'; + +import { SceneVariable, SceneVariableSet, SceneVariableSetState, SceneVariableState } from './types'; + +export class TextBoxSceneVariable extends SceneObjectBase implements SceneVariable {} + +export class SceneVariableManager extends SceneObjectBase implements SceneVariableSet { + getVariableByName(name: string): SceneVariable | undefined { + // TODO: Replace with index + return this.state.variables.find((x) => x.state.name === name); + } +} + +export function sceneTemplateInterpolator(target: string, sceneObject: SceneObject) { + variableRegex.lastIndex = 0; + + return target.replace(variableRegex, (match, var1, var2, fmt2, var3, fieldPath, fmt3) => { + const variableName = var1 || var2 || var3; + const variable = lookupSceneVariable(variableName, sceneObject); + + if (!variable) { + return match; + } + + return variable.state.current.value; + }); +} + +function lookupSceneVariable(name: string, sceneObject: SceneObject): SceneVariable | null | undefined { + const variables = sceneObject.state.$variables; + if (!variables) { + if (sceneObject.parent) { + return lookupSceneVariable(name, sceneObject.parent); + } else { + return null; + } + } + + const found = variables.getVariableByName(name); + if (found) { + return found; + } else if (sceneObject.parent) { + return lookupSceneVariable(name, sceneObject.parent); + } + + return null; +} diff --git a/public/app/features/scenes/variables/types.ts b/public/app/features/scenes/variables/types.ts new file mode 100644 index 00000000000..b3b80e3fcd5 --- /dev/null +++ b/public/app/features/scenes/variables/types.ts @@ -0,0 +1,24 @@ +import { LoadingState } from '@grafana/data'; +import { VariableHide } from 'app/features/variables/types'; + +import { SceneObject, SceneObjectState } from '../core/types'; + +export interface SceneVariableState extends SceneObjectState { + name: string; + hide?: VariableHide; + skipUrlSync?: boolean; + state?: LoadingState; + error?: any | null; + description?: string | null; + current: { value: string; text?: string }; +} + +export interface SceneVariable extends SceneObject {} + +export interface SceneVariableSetState extends SceneObjectState { + variables: SceneVariable[]; +} + +export interface SceneVariableSet extends SceneObject { + getVariableByName(name: string): SceneVariable | undefined; +} diff --git a/public/app/features/search/components/DashboardListPage.tsx b/public/app/features/search/components/DashboardListPage.tsx index 120ed95b645..bbb5b34bd17 100644 --- a/public/app/features/search/components/DashboardListPage.tsx +++ b/public/app/features/search/components/DashboardListPage.tsx @@ -5,7 +5,7 @@ import { useAsync } from 'react-use'; import { NavModel, locationUtil } from '@grafana/data'; import { locationService } from '@grafana/runtime'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { getNavModel } from 'app/core/selectors/navModel'; import { FolderDTO, StoreState } from 'app/types'; diff --git a/public/app/features/serviceaccounts/ServiceAccountCreatePage.test.tsx b/public/app/features/serviceaccounts/ServiceAccountCreatePage.test.tsx index 603a7f8c8e7..0eca4499524 100644 --- a/public/app/features/serviceaccounts/ServiceAccountCreatePage.test.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountCreatePage.test.tsx @@ -2,11 +2,12 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { ServiceAccountCreatePageUnconnected, Props } from './ServiceAccountCreatePage'; +import { ServiceAccountCreatePage, Props } from './ServiceAccountCreatePage'; const postMock = jest.fn().mockResolvedValue({}); const patchMock = jest.fn().mockResolvedValue({}); const putMock = jest.fn().mockResolvedValue({}); + jest.mock('@grafana/runtime', () => ({ getBackendSrv: () => ({ post: postMock, @@ -26,6 +27,7 @@ jest.mock('@grafana/runtime', () => ({ licenseUrl: '', }, appSubUrl: '', + featureToggles: {}, }, })); @@ -52,7 +54,7 @@ const setup = (propOverrides: Partial) => { Object.assign(props, propOverrides); - render(); + render(); }; describe('ServiceAccountCreatePage tests', () => { diff --git a/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx b/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx index dc6bf19741d..5d2faca95f7 100644 --- a/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx @@ -1,34 +1,24 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { connect } from 'react-redux'; import { useHistory } from 'react-router-dom'; -import { NavModel } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; import { Form, Button, Input, Field } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; import { fetchBuiltinRoles, fetchRoleOptions, updateUserRoles } from 'app/core/components/RolePicker/api'; import { contextSrv } from 'app/core/core'; import { AccessControlAction, OrgRole, Role, ServiceAccountCreateApiResponse, ServiceAccountDTO } from 'app/types'; -import { getNavModel } from '../../core/selectors/navModel'; -import { StoreState } from '../../types'; import { OrgRolePicker } from '../admin/OrgRolePicker'; -export interface Props { - navModel: NavModel; -} - -const mapStateToProps = (state: StoreState) => ({ - navModel: getNavModel(state.navIndex, 'serviceaccounts'), -}); +export interface Props {} const createServiceAccount = async (sa: ServiceAccountDTO) => getBackendSrv().post('/api/serviceaccounts/', sa); const updateServiceAccount = async (id: number, sa: ServiceAccountDTO) => getBackendSrv().patch(`/api/serviceaccounts/${id}`, sa); -export const ServiceAccountCreatePageUnconnected = ({ navModel }: Props): JSX.Element => { +export const ServiceAccountCreatePage = ({}: Props): JSX.Element => { const [roleOptions, setRoleOptions] = useState([]); const [builtinRoles, setBuiltinRoles] = useState<{ [key: string]: Role[] }>({}); const [pendingRoles, setPendingRoles] = useState([]); @@ -109,7 +99,7 @@ export const ServiceAccountCreatePageUnconnected = ({ navModel }: Props): JSX.El }; return ( - +

    Create service account

    @@ -152,4 +142,4 @@ export const ServiceAccountCreatePageUnconnected = ({ navModel }: Props): JSX.El ); }; -export default connect(mapStateToProps)(ServiceAccountCreatePageUnconnected); +export default ServiceAccountCreatePage; diff --git a/public/app/features/serviceaccounts/ServiceAccountPage.test.tsx b/public/app/features/serviceaccounts/ServiceAccountPage.test.tsx index 142b4045507..d13d484cdc4 100644 --- a/public/app/features/serviceaccounts/ServiceAccountPage.test.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountPage.test.tsx @@ -23,14 +23,6 @@ const setup = (propOverrides: Partial) => { const updateServiceAccountMock = jest.fn(); const props: Props = { - navModel: { - main: { - text: 'Configuration', - }, - node: { - text: 'Service accounts', - }, - }, serviceAccount: {} as ServiceAccountDTO, tokens: [], builtInRoles: {}, diff --git a/public/app/features/serviceaccounts/ServiceAccountPage.tsx b/public/app/features/serviceaccounts/ServiceAccountPage.tsx index 82fed275471..75d4d9add87 100644 --- a/public/app/features/serviceaccounts/ServiceAccountPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountPage.tsx @@ -2,12 +2,11 @@ import { css } from '@emotion/css'; import React, { useEffect, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { getTimeZone, GrafanaTheme2, NavModel } from '@grafana/data'; +import { getTimeZone, GrafanaTheme2 } from '@grafana/data'; import { Button, ConfirmModal, IconButton, useStyles2 } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { getNavModel } from 'app/core/selectors/navModel'; import { AccessControlAction, ApiKey, Role, ServiceAccountDTO, StoreState } from 'app/types'; import { CreateTokenModal, ServiceAccountToken } from './components/CreateTokenModal'; @@ -24,7 +23,6 @@ import { } from './state/actionsServiceAccountPage'; interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> { - navModel: NavModel; serviceAccount?: ServiceAccountDTO; tokens: ApiKey[]; isLoading: boolean; @@ -34,7 +32,6 @@ interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> { function mapStateToProps(state: StoreState) { return { - navModel: getNavModel(state.navIndex, 'serviceaccounts'), serviceAccount: state.serviceAccountProfile.serviceAccount, tokens: state.serviceAccountProfile.tokens, isLoading: state.serviceAccountProfile.isLoading, @@ -58,7 +55,6 @@ const connector = connect(mapStateToProps, mapDispatchToProps); export type Props = OwnProps & ConnectedProps; export const ServiceAccountPageUnconnected = ({ - navModel, match, serviceAccount, tokens, @@ -131,7 +127,7 @@ export const ServiceAccountPageUnconnected = ({ }; return ( - + {serviceAccount && (
    diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx index e2222f4f628..59a66ade42a 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.test.tsx @@ -26,14 +26,6 @@ const setup = (propOverrides: Partial) => { const getApiKeysMigrationInfoMock = jest.fn(); const closeApiKeysMigrationInfoMock = jest.fn(); const props: Props = { - navModel: { - main: { - text: 'Configuration', - }, - node: { - text: 'Service accounts', - }, - }, builtInRoles: {}, isLoading: false, page: 0, diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx index 6043c9b5e8e..1d87856501a 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx @@ -6,10 +6,9 @@ import { connect, ConnectedProps } from 'react-redux'; import { GrafanaTheme2, OrgRole } from '@grafana/data'; import { Alert, ConfirmModal, FilterInput, Icon, LinkButton, RadioButtonGroup, Tooltip, useStyles2 } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import PageLoader from 'app/core/components/PageLoader/PageLoader'; import { contextSrv } from 'app/core/core'; -import { getNavModel } from 'app/core/selectors/navModel'; import { StoreState, ServiceAccountDTO, AccessControlAction, ServiceAccountStateFilter } from 'app/types'; import { CreateTokenModal, ServiceAccountToken } from './components/CreateTokenModal'; @@ -33,7 +32,6 @@ export type Props = OwnProps & ConnectedProps; function mapStateToProps(state: StoreState) { return { - navModel: getNavModel(state.navIndex, 'serviceaccounts'), ...state.serviceAccounts, }; } @@ -54,7 +52,6 @@ const mapDispatchToProps = { const connector = connect(mapStateToProps, mapDispatchToProps); export const ServiceAccountsListPageUnconnected = ({ - navModel, serviceAccounts, isLoading, roleOptions, @@ -169,7 +166,7 @@ export const ServiceAccountsListPageUnconnected = ({ }; return ( - + {apiKeysMigrated && showApiKeysMigrationInfo && ( void; +} + +export function AddRootView({ onPathChange }: Props) { + return ( +
    +
    TODO... Add ROOT
    + +
    + ); +} diff --git a/public/app/features/storage/Breadcrumb.tsx b/public/app/features/storage/Breadcrumb.tsx new file mode 100644 index 00000000000..f2a824a54e4 --- /dev/null +++ b/public/app/features/storage/Breadcrumb.tsx @@ -0,0 +1,60 @@ +import { css } from '@emotion/css'; +import { uniqueId } from 'lodash'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Icon, IconName, useStyles2 } from '@grafana/ui'; + +interface Props { + rootIcon?: IconName; + pathName: string; + onPathChange: (path: string) => void; +} + +export function Breadcrumb({ pathName, onPathChange, rootIcon }: Props) { + const styles = useStyles2(getStyles); + const paths = pathName.split('/').filter(Boolean); + + return ( +
      + {rootIcon && ( +
    • onPathChange('')}> + +
    • + )} + {paths.map((path, index) => { + let url = '/' + paths.slice(0, index + 1).join('/'); + const onClickBreadcrumb = () => onPathChange(url); + const isLastBreadcrumb = index === paths.length - 1; + return ( +
    • + {path} +
    • + ); + })} +
    + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + breadCrumb: css` + list-style: none; + padding: ${theme.spacing(2, 1)}; + + li { + display: inline; + + :not(:last-child) { + color: ${theme.colors.text.link}; + cursor: pointer; + } + + li:before { + content: '>'; + padding: ${theme.spacing(1)}; + color: ${theme.colors.text.secondary}; + } + } + `, + }; +} diff --git a/public/app/features/storage/ExportView.tsx b/public/app/features/storage/ExportView.tsx new file mode 100644 index 00000000000..ed983e1639c --- /dev/null +++ b/public/app/features/storage/ExportView.tsx @@ -0,0 +1,127 @@ +import React, { useEffect, useState } from 'react'; + +import { isLiveChannelMessageEvent, isLiveChannelStatusEvent, LiveChannelScope } from '@grafana/data'; +import { getBackendSrv, getGrafanaLiveSrv } from '@grafana/runtime'; +import { Button, CodeEditor, Modal } from '@grafana/ui'; + +import { StorageView } from './types'; + +interface ExportStatusMessage { + running: boolean; + target: string; + started: number; + finished: number; + update: number; + count: number; + current: number; + last: string; + status: string; +} + +interface Props { + onPathChange: (p: string, v?: StorageView) => void; +} + +export const ExportView = ({ onPathChange }: Props) => { + const [status, setStatus] = useState(); + + const [open, setOpen] = useState(false); + const [body, setBody] = useState({ + format: 'git', + git: {}, + }); + const onDismiss = () => setOpen(false); + const doStart = () => { + getBackendSrv() + .post('/api/admin/export', body) + .then((v) => { + onDismiss(); + }); + }; + + useEffect(() => { + const subscription = getGrafanaLiveSrv() + .getStream({ + scope: LiveChannelScope.Grafana, + namespace: 'broadcast', + path: 'export', + }) + .subscribe({ + next: (evt) => { + if (isLiveChannelMessageEvent(evt)) { + setStatus(evt.message); + } else if (isLiveChannelStatusEvent(evt)) { + setStatus(evt.message); + } + }, + }); + + // if not running, open the thread + setTimeout(() => { + if (!status) { + setOpen(true); + } + }, 500); + + return () => { + subscription.unsubscribe(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const renderButton = () => { + return ( + <> + +
    + { + setBody(JSON.parse(text)); // force JSON? + }} + /> +
    + + + + +
    + + + + + ); + }; + + if (!status) { + return
    {renderButton()}
    ; + } + + return ( +
    +
    {JSON.stringify(status, null, 2)}
    + {Boolean(!status.running) && renderButton()} + {Boolean(status.running) && ( + + )} +
    + ); +}; diff --git a/public/app/features/storage/FileView.tsx b/public/app/features/storage/FileView.tsx new file mode 100644 index 00000000000..9051b4acec2 --- /dev/null +++ b/public/app/features/storage/FileView.tsx @@ -0,0 +1,161 @@ +import { css } from '@emotion/css'; +import { isString } from 'lodash'; +import React, { useMemo } from 'react'; +import SVG from 'react-inlinesvg'; +import { useAsync } from 'react-use'; +import AutoSizer from 'react-virtualized-auto-sizer'; + +import { DataFrame, GrafanaTheme2 } from '@grafana/data'; +import { CodeEditor, useStyles2 } from '@grafana/ui'; + +import { getGrafanaStorage } from './helper'; +import { StorageView } from './types'; + +interface FileDisplayInfo { + category?: 'svg' | 'image' | 'text'; + language?: string; // match code editor +} + +interface Props { + listing: DataFrame; + path: string; + onPathChange: (p: string, view?: StorageView) => void; + view: StorageView; +} + +export function FileView({ listing, path, onPathChange, view }: Props) { + const styles = useStyles2(getStyles); + const info = useMemo(() => getFileDisplayInfo(path), [path]); + const body = useAsync(async () => { + if (info.category === 'text') { + const rsp = await getGrafanaStorage().get(path); + if (isString(rsp)) { + return rsp; + } + return JSON.stringify(rsp, null, 2); + } + return null; + }, [info, path]); + + switch (view) { + case StorageView.Config: + return
    CONFIGURE?
    ; + case StorageView.Perms: + return
    Permissions
    ; + case StorageView.History: + return
    TODO... history
    ; + } + + let src = `api/storage/read/${path}`; + if (src.endsWith('/')) { + src = src.substring(0, src.length - 1); + } + + switch (info.category) { + case 'svg': + return ( +
    + +
    + ); + case 'image': + return ( +
    + + + +
    + ); + case 'text': + return ( +
    + + {({ width, height }) => ( + { + console.log('CHANGED!', text); + }} + /> + )} + +
    + ); + } + + return ( +
    + FILE: {path} +
    + ); +} + +function getFileDisplayInfo(path: string): FileDisplayInfo { + const idx = path.lastIndexOf('.'); + if (idx < 0) { + return {}; + } + const suffix = path.substring(idx + 1).toLowerCase(); + switch (suffix) { + case 'svg': + return { category: 'svg' }; + case 'jpg': + case 'jpeg': + case 'png': + case 'webp': + case 'gif': + return { category: 'image' }; + + case 'geojson': + case 'json': + return { category: 'text', language: 'json' }; + case 'text': + case 'go': + case 'md': + return { category: 'text' }; + } + return {}; +} + +const getStyles = (theme: GrafanaTheme2) => ({ + // TODO: remove `height: 90%` + wrapper: css` + display: flex; + flex-direction: column; + height: 100%; + `, + tableControlRowWrapper: css` + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: ${theme.spacing(2)}; + `, + // TODO: remove `height: 100%` + tableWrapper: css` + border: 1px solid ${theme.colors.border.medium}; + height: 100%; + `, + uploadSpot: css` + margin-left: ${theme.spacing(2)}; + `, + border: css` + border: 1px solid ${theme.colors.border.medium}; + padding: ${theme.spacing(2)}; + `, + img: css` + max-width: 100%; + // max-height: 147px; + // fill: ${theme.colors.text.primary}; + `, + icon: css` + // max-width: 100%; + // max-height: 147px; + // fill: ${theme.colors.text.primary}; + `, +}); diff --git a/public/app/features/storage/FolderView.tsx b/public/app/features/storage/FolderView.tsx new file mode 100644 index 00000000000..9a10b8beaf1 --- /dev/null +++ b/public/app/features/storage/FolderView.tsx @@ -0,0 +1,87 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import AutoSizer from 'react-virtualized-auto-sizer'; + +import { DataFrame, GrafanaTheme2 } from '@grafana/data'; +import { Table, useStyles2 } from '@grafana/ui'; + +import { UploadView } from './UploadView'; +import { StorageView } from './types'; + +interface Props { + listing: DataFrame; + path: string; + onPathChange: (p: string, view?: StorageView) => void; + view: StorageView; +} + +export function FolderView({ listing, path, onPathChange, view }: Props) { + const styles = useStyles2(getStyles); + + switch (view) { + case StorageView.Config: + return
    CONFIGURE?
    ; + case StorageView.Perms: + return
    Permissions
    ; + case StorageView.Upload: + return ( + { + console.log('Uploaded: ' + path); + if (rsp.path) { + onPathChange(rsp.path); + } else { + onPathChange(path); // back to data + } + }} + /> + ); + } + + return ( +
    + + {({ width, height }) => ( +
    + + + )} + + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + // TODO: remove `height: 90%` + wrapper: css` + display: flex; + flex-direction: column; + height: 100%; + `, + tableControlRowWrapper: css` + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: ${theme.spacing(2)}; + `, + // TODO: remove `height: 100%` + tableWrapper: css` + border: 1px solid ${theme.colors.border.medium}; + height: 100%; + `, + uploadSpot: css` + margin-left: ${theme.spacing(2)}; + `, + border: css` + border: 1px solid ${theme.colors.border.medium}; + padding: ${theme.spacing(2)}; + `, +}); diff --git a/public/app/features/storage/RootView.tsx b/public/app/features/storage/RootView.tsx new file mode 100644 index 00000000000..04b40ad5ceb --- /dev/null +++ b/public/app/features/storage/RootView.tsx @@ -0,0 +1,114 @@ +import { css } from '@emotion/css'; +import React, { useMemo, useState } from 'react'; + +import { DataFrame, DataFrameView, GrafanaTheme2 } from '@grafana/data'; +import { config } from '@grafana/runtime'; +import { Button, Card, FilterInput, Icon, IconName, TagList, useStyles2, VerticalGroup } from '@grafana/ui'; + +import { StorageView } from './types'; + +interface Props { + root: DataFrame; + onPathChange: (p: string, v?: StorageView) => void; +} + +interface RootFolder { + name: string; + title: string; + storageType: string; + description: string; + readOnly: boolean; + builtIn: boolean; +} + +export function RootView({ root, onPathChange }: Props) { + const styles = useStyles2(getStyles); + const [searchQuery, setSearchQuery] = useState(''); + let base = location.pathname; + if (!base.endsWith('/')) { + base += '/'; + } + + const roots = useMemo(() => { + const view = new DataFrameView(root); + const all = view.map((v) => ({ ...v })); + if (searchQuery?.length) { + const lower = searchQuery.toLowerCase(); + return all.filter((v) => { + const isMatch = v.name.toLowerCase().indexOf(lower) >= 0 || v.description.toLowerCase().indexOf(lower) >= 0; + if (isMatch) { + return true; + } + return false; + }); + } + return all; + }, [searchQuery, root]); + + return ( +
    +
    +
    + +
    + + {config.featureToggles.export && ( + + )} +
    + + {roots.map((v) => ( + + {v.title ?? v.name} + {v.description} + + + + + + + + ))} + +
    + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + secondaryTextColor: css` + color: ${theme.colors.text.secondary}; + `, + clickable: css` + pointer-events: none; + `, + }; +} + +function getTags(v: RootFolder) { + const tags: string[] = []; + if (v.builtIn) { + tags.push('Builtin'); + } + if (v.readOnly) { + tags.push('Read only'); + } + return tags; +} + +export function getIconName(type: string): IconName { + switch (type) { + case 'git': + return 'code-branch'; + case 'disk': + return 'folder-open'; + case 'sql': + return 'database'; + default: + return 'folder-open'; + } +} diff --git a/public/app/features/storage/StoragePage.tsx b/public/app/features/storage/StoragePage.tsx new file mode 100644 index 00000000000..4d53c6f1b2c --- /dev/null +++ b/public/app/features/storage/StoragePage.tsx @@ -0,0 +1,205 @@ +import { css } from '@emotion/css'; +import React, { useMemo } from 'react'; +import { useAsync } from 'react-use'; + +import { DataFrame, GrafanaTheme2, isDataFrame, ValueLinkConfig } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { useStyles2, IconName, Spinner, TabsBar, Tab, Button, HorizontalGroup } from '@grafana/ui'; +import { Page } from 'app/core/components/Page/Page'; +import { useNavModel } from 'app/core/hooks/useNavModel'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; + +import { AddRootView } from './AddRootView'; +import { Breadcrumb } from './Breadcrumb'; +import { ExportView } from './ExportView'; +import { FileView } from './FileView'; +import { FolderView } from './FolderView'; +import { RootView } from './RootView'; +import { getGrafanaStorage } from './helper'; +import { StorageView } from './types'; + +interface RouteParams { + path: string; +} + +interface QueryParams { + view: StorageView; +} + +interface Props extends GrafanaRouteComponentProps {} + +export default function StoragePage(props: Props) { + const styles = useStyles2(getStyles); + const navModel = useNavModel('storage'); + const path = props.match.params.path ?? ''; + const view = props.queryParams.view ?? StorageView.Data; + const setPath = (p: string, view?: StorageView) => { + let url = ('/admin/storage/' + p).replace('//', '/'); + if (view && view !== StorageView.Data) { + url += '?view=' + view; + } + locationService.push(url); + }; + + const listing = useAsync((): Promise => { + return getGrafanaStorage() + .list(path) + .then((frame) => { + if (frame) { + const name = frame.fields[0]; + frame.fields[0] = { + ...name, + getLinks: (cfg: ValueLinkConfig) => { + const n = name.values.get(cfg.valueRowIndex ?? 0); + const p = path + '/' + n; + return [ + { + title: `Open ${n}`, + href: `/admin/storage/${p}`, + target: '_self', + origin: name, + onClick: () => { + setPath(p); + }, + }, + ]; + }, + }; + } + return frame; + }); + }, [path]); + + const isFolder = useMemo(() => { + let isFolder = path?.indexOf('/') < 0; + if (listing.value) { + const length = listing.value.length; + if (length > 1) { + isFolder = true; + } + if (length === 1) { + const first = listing.value.fields[0].values.get(0) as string; + isFolder = !path.endsWith(first); + } + } + return isFolder; + }, [path, listing]); + + const renderView = () => { + const isRoot = !path?.length || path === '/'; + switch (view) { + case StorageView.Export: + if (!isRoot) { + setPath(''); + return ; + } + return ; + + case StorageView.AddRoot: + if (!isRoot) { + setPath(''); + return ; + } + return ; + } + + const frame = listing.value; + if (!isDataFrame(frame)) { + return <>; + } + + if (isRoot) { + return ; + } + + const opts = [{ what: StorageView.Data, text: 'Data' }]; + + // Root folders have a config page + if (path.indexOf('/') < 0) { + opts.push({ what: StorageView.Config, text: 'Configure' }); + } + + // Lets only apply permissions to folders (for now) + if (isFolder) { + opts.push({ what: StorageView.Perms, text: 'Permissions' }); + } else { + // TODO: only if the file exists in a storage engine with + opts.push({ what: StorageView.History, text: 'History' }); + } + + // Hardcode the uploadable folder :) + if (isFolder && path.startsWith('resources')) { + opts.push({ + what: StorageView.Upload, + text: 'Upload', + }); + } + const canAddFolder = isFolder && path.startsWith('resources'); + const canDelete = !isFolder && path.startsWith('resources/'); + + return ( +
    + + +
    + {canAddFolder && } + {canDelete && ( + + )} +
    +
    + + + {opts.map((opt) => ( + setPath(path, opt.what)} + /> + ))} + + {isFolder ? ( + + ) : ( + + )} +
    + ); + }; + + return ( + + {renderView()} + + ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + // TODO: remove `height: 90%` + wrapper: css` + display: flex; + flex-direction: column; + height: 100%; + `, + tableControlRowWrapper: css` + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: ${theme.spacing(2)}; + `, + // TODO: remove `height: 100%` + tableWrapper: css` + border: 1px solid ${theme.colors.border.medium}; + height: 100%; + `, + uploadSpot: css` + margin-left: ${theme.spacing(2)}; + `, + border: css` + border: 1px solid ${theme.colors.border.medium}; + padding: ${theme.spacing(2)}; + `, +}); diff --git a/public/app/features/storage/UploadView.tsx b/public/app/features/storage/UploadView.tsx new file mode 100644 index 00000000000..370f5370666 --- /dev/null +++ b/public/app/features/storage/UploadView.tsx @@ -0,0 +1,136 @@ +import { css } from '@emotion/css'; +import React, { useState } from 'react'; +import SVG from 'react-inlinesvg'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Button, ButtonGroup, Field, FileDropzone, useStyles2 } from '@grafana/ui'; + +import { getGrafanaStorage } from './helper'; +import { UploadReponse } from './types'; + +interface Props { + folder: string; + onUpload: (rsp: UploadReponse) => void; +} + +interface ErrorResponse { + message: string; +} + +const FileDropzoneCustomChildren = ({ secondaryText = 'Drag and drop here or browse' }) => { + const styles = useStyles2(getStyles); + + return ( +
    + {secondaryText} +
    + ); +}; + +export const UploadView = ({ folder, onUpload }: Props) => { + const [file, setFile] = useState(undefined); + + const styles = useStyles2(getStyles); + + const [error, setError] = useState({ message: '' }); + + const Preview = () => { + if (!file) { + return <>; + } + const isImage = file.type?.startsWith('image/'); + const isSvg = file.name?.endsWith('.svg'); + + const src = URL.createObjectURL(file); + return ( + +
    + {isSvg && } + {isImage && !isSvg && } +
    +
    + ); + }; + + const doUpload = async () => { + if (!file) { + setError({ message: 'please select a file' }); + return; + } + + const rsp = await getGrafanaStorage().upload(folder, file); + if (rsp.status !== 200) { + setError(rsp); + } else { + onUpload(rsp); + } + }; + + return ( +
    + { + setFile(undefined); + }} + options={{ + accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.webp'] }, + multiple: false, + onDrop: (acceptedFiles: File[]) => { + setFile(acceptedFiles[0]); + }, + }} + > + {error.message !== '' ?

    {error.message}

    : Boolean(file) ? : } +
    + + + + +
    + ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + resourcePickerPopover: css` + border-radius: ${theme.shape.borderRadius()}; + box-shadow: ${theme.shadows.z3}; + background: ${theme.colors.background.primary}; + border: 1px solid ${theme.colors.border.medium}; + `, + resourcePickerPopoverContent: css` + width: 315px; + font-size: ${theme.typography.bodySmall.fontSize}; + min-height: 184px; + padding: ${theme.spacing(1)}; + display: flex; + flex-direction: column; + `, + button: css` + margin: 12px 20px 5px; + `, + iconPreview: css` + width: 238px; + height: 198px; + border: 1px solid ${theme.colors.border.medium}; + display: flex; + align-items: center; + justify-content: center; + `, + img: css` + width: 147px; + height: 147px; + fill: ${theme.colors.text.primary}; + `, + iconWrapper: css` + display: flex; + flex-direction: column; + align-items: center; + `, + small: css` + color: ${theme.colors.text.secondary}; + margin-bottom: ${theme.spacing(2)}; + `, +}); diff --git a/public/app/features/storage/helper.ts b/public/app/features/storage/helper.ts new file mode 100644 index 00000000000..f93c15d841c --- /dev/null +++ b/public/app/features/storage/helper.ts @@ -0,0 +1,66 @@ +import { DataFrame, dataFrameFromJSON, DataFrameJSON, getDisplayProcessor } from '@grafana/data'; +import { config, getBackendSrv } from '@grafana/runtime'; + +import { UploadReponse } from './types'; + +// Likely should be built into the search interface! +export interface GrafanaStorage { + get: (path: string) => Promise; + list: (path: string) => Promise; + upload: (folder: string, file: File) => Promise; +} + +class SimpleStorage implements GrafanaStorage { + constructor() {} + + async get(path: string): Promise { + const storagePath = `api/storage/read/${path}`.replace('//', '/'); + return getBackendSrv().get(storagePath); + } + + async list(path: string): Promise { + let url = 'api/storage/list/'; + if (path) { + url += path + '/'; + } + const rsp = await getBackendSrv().get(url); + if (rsp?.data) { + const f = dataFrameFromJSON(rsp); + for (const field of f.fields) { + field.display = getDisplayProcessor({ field, theme: config.theme2 }); + } + return f; + } + return undefined; + } + + async upload(folder: string, file: File): Promise { + const formData = new FormData(); + formData.append('folder', folder); + formData.append('file', file); + const res = await fetch('/api/storage/upload', { + method: 'POST', + body: formData, + }); + + let body = (await res.json()) as UploadReponse; + if (!body) { + body = {} as any; + } + body.status = res.status; + body.statusText = res.statusText; + if (res.status !== 200 && !body.err) { + body.err = true; + } + return body; + } +} + +let storage: GrafanaStorage | undefined; + +export function getGrafanaStorage() { + if (!storage) { + storage = new SimpleStorage(); + } + return storage; +} diff --git a/public/app/features/storage/types.ts b/public/app/features/storage/types.ts new file mode 100644 index 00000000000..9a729dee3b9 --- /dev/null +++ b/public/app/features/storage/types.ts @@ -0,0 +1,18 @@ +export enum StorageView { + Data = 'data', + Config = 'config', + Perms = 'perms', + Upload = 'upload', + Export = 'export', + History = 'history', + AddRoot = 'add', +} + +export interface UploadReponse { + status: number; + statusText: string; + + err?: boolean; + message: string; + path: string; +} diff --git a/public/app/features/teams/CreateTeam.test.tsx b/public/app/features/teams/CreateTeam.test.tsx index 5c8c2aa5f27..404dcc23dda 100644 --- a/public/app/features/teams/CreateTeam.test.tsx +++ b/public/app/features/teams/CreateTeam.test.tsx @@ -2,9 +2,9 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { NavModel } from '@grafana/data'; +import { BackendSrv, setBackendSrv } from '@grafana/runtime'; -import { CreateTeam, Props } from './CreateTeam'; +import { CreateTeam } from './CreateTeam'; beforeEach(() => { jest.clearAllMocks(); @@ -14,23 +14,12 @@ const mockPost = jest.fn(() => { return Promise.resolve({}); }); -jest.mock('@grafana/runtime', () => ({ - getBackendSrv: () => { - return { - post: mockPost, - }; - }, - config: { - buildInfo: {}, - licenseInfo: {}, - }, -})); +setBackendSrv({ + post: mockPost, +} as any as BackendSrv); const setup = () => { - const props: Props = { - navModel: { node: {}, main: {} } as NavModel, - }; - return render(); + return render(); }; describe('Create team', () => { diff --git a/public/app/features/teams/CreateTeam.tsx b/public/app/features/teams/CreateTeam.tsx index 1c35b134e4b..b0fabfcef5f 100644 --- a/public/app/features/teams/CreateTeam.tsx +++ b/public/app/features/teams/CreateTeam.tsx @@ -1,24 +1,16 @@ import React, { PureComponent } from 'react'; -import { connect } from 'react-redux'; -import { NavModel } from '@grafana/data'; import { getBackendSrv, locationService } from '@grafana/runtime'; import { Button, Form, Field, Input, FieldSet } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; -import { getNavModel } from 'app/core/selectors/navModel'; -import { StoreState } from 'app/types'; - -export interface Props { - navModel: NavModel; -} interface TeamDTO { name: string; email: string; } -export class CreateTeam extends PureComponent { +export class CreateTeam extends PureComponent { create = async (formModel: TeamDTO) => { const result = await getBackendSrv().post('/api/teams', formModel); if (result.teamId) { @@ -27,10 +19,8 @@ export class CreateTeam extends PureComponent { } }; render() { - const { navModel } = this.props; - return ( - + {({ register, errors }) => ( @@ -58,10 +48,4 @@ export class CreateTeam extends PureComponent { } } -function mapStateToProps(state: StoreState) { - return { - navModel: getNavModel(state.navIndex, 'teams'), - }; -} - -export default connect(mapStateToProps)(CreateTeam); +export default CreateTeam; diff --git a/public/app/features/teams/TeamList.test.tsx b/public/app/features/teams/TeamList.test.tsx index 9575b0c1ed0..e81bd56ef65 100644 --- a/public/app/features/teams/TeamList.test.tsx +++ b/public/app/features/teams/TeamList.test.tsx @@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event'; import React from 'react'; import { mockToolkitActionCreator } from 'test/core/redux/mocks'; -import { NavModel } from '@grafana/data'; import { contextSrv, User } from 'app/core/services/context_srv'; import { OrgRole, Team } from '../../types'; @@ -20,14 +19,6 @@ jest.mock('app/core/config', () => { const setup = (propOverrides?: object) => { const props: Props = { - navModel: { - main: { - text: 'Configuration', - }, - node: { - text: 'Team List', - }, - } as NavModel, teams: [] as Team[], loadTeams: jest.fn(), deleteTeam: jest.fn(), diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index 7f2cda0992b..e744fdd7993 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -1,13 +1,11 @@ import React, { PureComponent } from 'react'; -import { NavModel } from '@grafana/data'; import { DeleteButton, LinkButton, FilterInput, VerticalGroup, HorizontalGroup, Pagination } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { TeamRolePicker } from 'app/core/components/RolePicker/TeamRolePicker'; import { fetchRoleOptions } from 'app/core/components/RolePicker/api'; import { config } from 'app/core/config'; -import { getNavModel } from 'app/core/selectors/navModel'; import { contextSrv, User } from 'app/core/services/context_srv'; import { AccessControlAction, Role, StoreState, Team } from 'app/types'; @@ -20,7 +18,6 @@ import { getSearchQuery, getTeams, getTeamsCount, getTeamsSearchPage, isPermissi const pageLimit = 30; export interface Props { - navModel: NavModel; teams: Team[]; searchQuery: string; searchPage: number; @@ -224,10 +221,10 @@ export class TeamList extends PureComponent { } render() { - const { hasFetched, navModel } = this.props; + const { hasFetched } = this.props; return ( - + {this.renderList()} ); @@ -236,7 +233,6 @@ export class TeamList extends PureComponent { function mapStateToProps(state: StoreState) { return { - navModel: getNavModel(state.navIndex, 'teams'), teams: getTeams(state.teams), searchQuery: getSearchQuery(state.teams), searchPage: getTeamsSearchPage(state.teams), diff --git a/public/app/features/teams/TeamPages.test.tsx b/public/app/features/teams/TeamPages.test.tsx index 12d024e0fb9..7184ec3931b 100644 --- a/public/app/features/teams/TeamPages.test.tsx +++ b/public/app/features/teams/TeamPages.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { Provider } from 'react-redux'; -import { NavModel, createTheme } from '@grafana/data'; +import { createTheme } from '@grafana/data'; import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; import { User } from 'app/core/services/context_srv'; import { configureStore } from 'app/store/configureStore'; @@ -68,7 +68,7 @@ const setup = (propOverrides?: object) => { }, } as any, }), - navModel: { node: {}, main: {} } as NavModel, + pageNav: { text: 'Cool team ' }, teamId: 1, loadTeam: jest.fn(), loadTeamMembers: jest.fn(), diff --git a/public/app/features/teams/TeamPages.tsx b/public/app/features/teams/TeamPages.tsx index f72b7f0b532..6c0968237c6 100644 --- a/public/app/features/teams/TeamPages.tsx +++ b/public/app/features/teams/TeamPages.tsx @@ -2,10 +2,10 @@ import { includes } from 'lodash'; import React, { PureComponent } from 'react'; import { connect, ConnectedProps } from 'react-redux'; -import { NavModel } from '@grafana/data'; +import { NavModelItem } from '@grafana/data'; import { featureEnabled } from '@grafana/runtime'; import { Themeable2, withTheme2 } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { UpgradeBox } from 'app/core/components/Upgrade/UpgradeBox'; import config from 'app/core/config'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; @@ -51,11 +51,11 @@ function mapStateToProps(state: StoreState, props: OwnProps) { } const pageName = props.match.params.page ?? defaultPage; const teamLoadingNav = getTeamLoadingNav(pageName as string); - const navModel = getNavModel(state.navIndex, `team-${pageName}-${teamId}`, teamLoadingNav); + const pageNav = getNavModel(state.navIndex, `team-${pageName}-${teamId}`, teamLoadingNav).main; const members = getTeamMembers(state.team); return { - navModel, + pageNav, teamId: teamId, pageName: pageName, team, @@ -118,20 +118,20 @@ export class TeamPages extends PureComponent { return text1.toLocaleLowerCase() === text2.toLocaleLowerCase(); }; - hideTabsFromNonTeamAdmin = (navModel: NavModel, isSignedInUserTeamAdmin: boolean) => { + hideTabsFromNonTeamAdmin = (pageNav: NavModelItem, isSignedInUserTeamAdmin: boolean) => { if (contextSrv.accessControlEnabled()) { - return navModel; + return pageNav; } - if (!isSignedInUserTeamAdmin && navModel.main && navModel.main.children) { - navModel.main.children + if (!isSignedInUserTeamAdmin && pageNav && pageNav.children) { + pageNav.children .filter((navItem) => !this.textsAreEqual(navItem.text, PageTypes.Members)) .map((navItem) => { navItem.hideFromTabs = true; }); } - return navModel; + return pageNav; }; renderPage(isSignedInUserTeamAdmin: boolean): React.ReactNode { @@ -183,11 +183,11 @@ export class TeamPages extends PureComponent { } render() { - const { team, navModel, members, editorsCanAdmin, signedInUser } = this.props; + const { team, pageNav, members, editorsCanAdmin, signedInUser } = this.props; const isTeamAdmin = isSignedInUserTeamAdmin({ members, editorsCanAdmin, signedInUser }); return ( - + {team && Object.keys(team).length !== 0 && this.renderPage(isTeamAdmin)} diff --git a/public/app/features/teams/TeamPermissions.tsx b/public/app/features/teams/TeamPermissions.tsx index 51a17bc388e..2d4d23386e8 100644 --- a/public/app/features/teams/TeamPermissions.tsx +++ b/public/app/features/teams/TeamPermissions.tsx @@ -18,7 +18,7 @@ const TeamPermissions = (props: TeamPermissionsProps) => { return ( ({ const setup = (propOverrides?: object) => { const store = configureStore(); const props: Props = { - navModel: { - main: { - text: 'Configuration', - }, - node: { - text: 'Users', - }, - } as NavModel, users: [] as OrgUser[], invitees: [] as Invitee[], searchQuery: '', diff --git a/public/app/features/users/UsersListPage.tsx b/public/app/features/users/UsersListPage.tsx index 6205a86a7c1..7cab296441d 100644 --- a/public/app/features/users/UsersListPage.tsx +++ b/public/app/features/users/UsersListPage.tsx @@ -3,9 +3,8 @@ import { connect, ConnectedProps } from 'react-redux'; import { renderMarkdown } from '@grafana/data'; import { HorizontalGroup, Pagination, VerticalGroup } from '@grafana/ui'; -import Page from 'app/core/components/Page/Page'; +import { Page } from 'app/core/components/Page/Page'; import { contextSrv } from 'app/core/core'; -import { getNavModel } from 'app/core/selectors/navModel'; import { OrgUser, OrgRole, StoreState } from 'app/types'; import InviteesTable from '../invites/InviteesTable'; @@ -21,7 +20,6 @@ import { getUsers, getUsersSearchQuery, getUsersSearchPage } from './state/selec function mapStateToProps(state: StoreState) { const searchQuery = getUsersSearchQuery(state.users); return { - navModel: getNavModel(state.navIndex, 'users'), users: getUsers(state.users), searchQuery: getUsersSearchQuery(state.users), searchPage: getUsersSearchPage(state.users), @@ -125,11 +123,11 @@ export class UsersListPage extends PureComponent { } render() { - const { navModel, hasFetched } = this.props; + const { hasFetched } = this.props; const externalUserMngInfoHtml = this.externalUserMngInfoHtml; return ( - + <> diff --git a/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx b/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx index 76fa6c02b4e..b9445beced0 100644 --- a/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx +++ b/public/app/plugins/datasource/cloud-monitoring/components/AnnotationQueryEditor.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { useDebounce } from 'react-use'; import { QueryEditorProps, toOption } from '@grafana/data'; +import { EditorField, EditorRows, EditorRow } from '@grafana/experimental'; import { config } from '@grafana/runtime'; import { Input } from '@grafana/ui'; @@ -79,38 +80,52 @@ export const AnnotationQueryEditor = (props: Props) => { ); return ( - <> + {config.featureToggles.cloudMonitoringExperimentalUI ? ( - + <> + + + + + + + + + + + + + + ) : ( - + <> + + + + + + + + + )} - - - - - - - - - - + ); }; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/Experimental/LabelFilter.test.tsx b/public/app/plugins/datasource/cloud-monitoring/components/Experimental/LabelFilter.test.tsx new file mode 100644 index 00000000000..afa143e7404 --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/components/Experimental/LabelFilter.test.tsx @@ -0,0 +1,92 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { openMenu, select } from 'react-select-event'; + +import { LabelFilter } from './LabelFilter'; + +const labels = { + 'metric.label.instance_name': ['instance_name_1', 'instance_name_2'], + 'resource.label.instance_id': ['instance_id_1', 'instance_id_2'], + 'resource.label.project_id': ['project_id_1', 'project_id_2'], + 'resource.label.zone': ['zone_1', 'zone_2'], + 'resource.type': ['type_1', 'type_2'], +}; + +describe('LabelFilter', () => { + it('should render an add button with no filters passed in', () => { + render( {}} variableOptionGroup={[]} />); + expect(screen.getByLabelText('Add')).toBeInTheDocument(); + }); + + it('should render filters if any are passed in', () => { + const filters = ['key_1', '=', 'value_1']; + render( {}} variableOptionGroup={[]} />); + expect(screen.getByText('key_1')).toBeInTheDocument(); + expect(screen.getByText('value_1')).toBeInTheDocument(); + }); + + it('can add filters', async () => { + const onChange = jest.fn(); + render(); + await userEvent.click(screen.getByLabelText('Add')); + expect(onChange).toBeCalledWith(expect.arrayContaining(['', '=', ''])); + }); + + it('should render grouped labels', async () => { + const filters = ['key_1', '=', 'value_1']; + render( {}} variableOptionGroup={[]} />); + + await openMenu(screen.getByLabelText('Filter label key')); + + expect(screen.getByText('Metric Label')).toBeInTheDocument(); + expect(screen.getByText('metric.label.instance_name')).toBeInTheDocument(); + + expect(screen.getByText('Resource Label')).toBeInTheDocument(); + expect(screen.getByText('resource.label.instance_id')).toBeInTheDocument(); + expect(screen.getByText('resource.label.project_id')).toBeInTheDocument(); + expect(screen.getByText('resource.label.zone')).toBeInTheDocument(); + + expect(screen.getByText('Resource Type')).toBeInTheDocument(); + expect(screen.getByText('resource.type')).toBeInTheDocument(); + }); + + it('can select a label key to filter on', async () => { + const onChange = jest.fn(); + const filters = ['key_1', '=', '']; + render(); + + const key = screen.getByLabelText('Filter label key'); + await select(key, 'metric.label.instance_name', { container: document.body }); + + expect(onChange).toBeCalledWith(expect.arrayContaining(['metric.label.instance_name', '=', ''])); + }); + + it('should on render label values for the selected filter key', async () => { + const filters = ['metric.label.instance_name', '=', '']; + render( {}} variableOptionGroup={[]} />); + + await openMenu(screen.getByLabelText('Filter label value')); + expect(screen.getByText('instance_name_1')).toBeInTheDocument(); + expect(screen.getByText('instance_name_2')).toBeInTheDocument(); + expect(screen.queryByText('instance_id_1')).not.toBeInTheDocument(); + expect(screen.queryByText('instance_id_2')).not.toBeInTheDocument(); + expect(screen.queryByText('project_id_1')).not.toBeInTheDocument(); + expect(screen.queryByText('project_id_2')).not.toBeInTheDocument(); + expect(screen.queryByText('zone_1')).not.toBeInTheDocument(); + expect(screen.queryByText('zone_2')).not.toBeInTheDocument(); + expect(screen.queryByText('type_1')).not.toBeInTheDocument(); + expect(screen.queryByText('type_2')).not.toBeInTheDocument(); + }); + + it('can select a label value to filter on', async () => { + const onChange = jest.fn(); + const filters = ['metric.label.instance_name', '=', '']; + render(); + + const key = screen.getByLabelText('Filter label value'); + await select(key, 'instance_name_1', { container: document.body }); + + expect(onChange).toBeCalledWith(expect.arrayContaining(['metric.label.instance_name', '=', 'instance_name_1'])); + }); +}); diff --git a/public/app/plugins/datasource/cloud-monitoring/components/Experimental/LabelFilter.tsx b/public/app/plugins/datasource/cloud-monitoring/components/Experimental/LabelFilter.tsx new file mode 100644 index 00000000000..04cee6619b1 --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/components/Experimental/LabelFilter.tsx @@ -0,0 +1,119 @@ +import React, { FunctionComponent, useMemo } from 'react'; + +import { SelectableValue, toOption } from '@grafana/data'; +import { AccessoryButton, EditorRow, EditorField, EditorList } from '@grafana/experimental'; +import { HorizontalGroup, Select } from '@grafana/ui'; + +import { labelsToGroupedOptions, stringArrayToFilters } from '../../functions'; + +export interface Props { + labels: { [key: string]: string[] }; + filters: string[]; + onChange: (filters: string[]) => void; + variableOptionGroup: SelectableValue; +} + +interface Filter { + key: string; + operator: string; + value: string; + condition: string; +} + +const DEFAULT_OPERATOR = '='; +const DEFAULT_CONDITION = 'AND'; + +const filtersToStringArray = (filters: Filter[]) => + filters.flatMap(({ key, operator, value, condition }) => [key, operator, value, condition]).slice(0, -1); + +const operators = ['=', '!=', '=~', '!=~'].map(toOption); + +export const LabelFilter: FunctionComponent = ({ + labels = {}, + filters: filterArray, + onChange: _onChange, + variableOptionGroup, +}) => { + const filters: Filter[] = useMemo(() => stringArrayToFilters(filterArray), [filterArray]); + const options = useMemo( + () => [variableOptionGroup, ...labelsToGroupedOptions(Object.keys(labels))], + [labels, variableOptionGroup] + ); + + const getOptions = ({ key = '', value = '' }: Partial) => { + // Add the current key and value as options if they are manually entered + const keyPresent = options.some((op) => { + if (op.options) { + return options.some((opp) => opp.label === key); + } + return op.label === key; + }); + if (!keyPresent) { + options.push({ label: key, value: key }); + } + + const valueOptions = labels.hasOwnProperty(key) + ? [variableOptionGroup, ...labels[key].map(toOption)] + : [variableOptionGroup]; + const valuePresent = valueOptions.some((op) => op.label === value); + if (!valuePresent) { + valueOptions.push({ label: value, value }); + } + + return { options, valueOptions }; + }; + + const onChange = (items: Array>) => { + const filters = items.map(({ key, operator, value, condition }) => ({ + key: key || '', + operator: operator || DEFAULT_OPERATOR, + value: value || '', + condition: condition || DEFAULT_CONDITION, + })); + _onChange(filtersToStringArray(filters)); + }; + + const renderItem = (item: Partial, onChangeItem: (item: Filter) => void, onDeleteItem: () => void) => { + const { key = '', operator = DEFAULT_OPERATOR, value = '', condition = DEFAULT_CONDITION } = item; + const { options, valueOptions } = getOptions(item); + + return ( + + onChangeItem({ key, operator, value, condition })} + /> + `Use project: ${v}`} + onChange={({ value }) => onChange(value!)} + options={projectsWithTemplateVariables} + value={{ value: projectName, label: projectName }} + placeholder="Select Project" + inputId={`${refId}-project`} + /> + + + ); +} diff --git a/public/app/plugins/datasource/cloud-monitoring/components/Experimental/SLO.tsx b/public/app/plugins/datasource/cloud-monitoring/components/Experimental/SLO.tsx new file mode 100644 index 00000000000..22294300551 --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/components/Experimental/SLO.tsx @@ -0,0 +1,57 @@ +import React, { useEffect, useState } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { EditorField, EditorRow } from '@grafana/experimental'; +import { Select } from '@grafana/ui'; + +import CloudMonitoringDatasource from '../../datasource'; +import { SLOQuery } from '../../types'; + +export interface Props { + refId: string; + onChange: (query: SLOQuery) => void; + query: SLOQuery; + templateVariableOptions: Array>; + datasource: CloudMonitoringDatasource; +} + +export const SLO: React.FC = ({ refId, query, templateVariableOptions, onChange, datasource }) => { + const [slos, setSLOs] = useState>>([]); + const { projectName, serviceId } = query; + + useEffect(() => { + if (!projectName || !serviceId) { + return; + } + + datasource.getServiceLevelObjectives(projectName, serviceId).then((sloIds: Array>) => { + setSLOs([ + { + label: 'Template Variables', + options: templateVariableOptions, + }, + ...sloIds, + ]); + }); + }, [datasource, projectName, serviceId, templateVariableOptions]); + + return ( + + + s.value === query?.selectorName ?? '')} + options={[ + { + label: 'Template Variables', + options: templateVariableOptions, + }, + ...SELECTORS, + ]} + onChange={({ value: selectorName }) => onChange({ ...query, selectorName: selectorName ?? '' })} + /> + + + ); +}; diff --git a/public/app/plugins/datasource/cloud-monitoring/components/Experimental/Service.tsx b/public/app/plugins/datasource/cloud-monitoring/components/Experimental/Service.tsx new file mode 100644 index 00000000000..c755e19976f --- /dev/null +++ b/public/app/plugins/datasource/cloud-monitoring/components/Experimental/Service.tsx @@ -0,0 +1,55 @@ +import React, { useEffect, useState } from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { EditorField, EditorRow } from '@grafana/experimental'; +import { Select } from '@grafana/ui'; + +import CloudMonitoringDatasource from '../../datasource'; +import { SLOQuery } from '../../types'; + +export interface Props { + refId: string; + onChange: (query: SLOQuery) => void; + query: SLOQuery; + templateVariableOptions: Array>; + datasource: CloudMonitoringDatasource; +} + +export const Service: React.FC = ({ refId, query, templateVariableOptions, onChange, datasource }) => { + const [services, setServices] = useState>>([]); + const { projectName } = query; + + useEffect(() => { + if (!projectName) { + return; + } + + datasource.getSLOServices(projectName).then((services: Array>) => { + setServices([ + { + label: 'Template Variables', + options: templateVariableOptions, + }, + ...services, + ]); + }); + }, [datasource, projectName, templateVariableOptions]); + + return ( + + +
    //the table to query (e.g. Usage, Heartbeat, Perf)\n| where $__timeFilter(TimeGenerated) //this is a macro used to show the full chart’s time range, choose the datetime column here\n| summarize count() by , bin(TimeGenerated, $__interval) //change “group by column” to a column in your table, such as “Computer”. The $__interval macro is used to auto-select the time grain. Can also use 1h, 5m etc.\n| order by TimeGenerated asc", - "resultFormat": "time_series", - "workspace": "657b3e91-7c0b-438b-86a5-f769445e237d" - }, - "azureMonitor": { - "aggOptions": ["None", "Average", "Minimum", "Maximum", "Total", "Count"], - "aggregation": "None", - "allowedTimeGrainsMs": [60000, 300000, 900000, 1800000, 3600000, 21600000, 43200000, 86400000], - "dimensionFilter": "*", - "dimensionFilters": [], - "dimensions": [], - "metricDefinition": "$ns", - "metricName": "SteamingIngestRequestRate", - "metricNamespace": "Microsoft.Kusto/clusters", - "resourceGroup": "$rg", - "resourceName": "$resource", - "timeGrain": "auto", - "timeGrains": [ - { - "text": "auto", - "value": "auto" - }, - { - "text": "1 minute", - "value": "PT1M" - }, - { - "text": "5 minutes", - "value": "PT5M" - }, - { - "text": "15 minutes", - "value": "PT15M" - }, - { - "text": "30 minutes", - "value": "PT30M" - }, - { - "text": "1 hour", - "value": "PT1H" - }, - { - "text": "6 hours", - "value": "PT6H" - }, - { - "text": "12 hours", - "value": "PT12H" - }, - { - "text": "1 day", - "value": "P1D" - } - ], - "top": "10" - }, - "insightsAnalytics": { - "query": "", - "resultFormat": "time_series" - }, - "queryType": "Azure Monitor", - "refId": "A", - "subscription": "$sub" - } - ], - "thresholds": [], - "timeFrom": null, - "timeRegions": [], - "timeShift": null, - "title": "Streaming Ingest Request Rate", - "tooltip": { - "shared": true, - "sort": 0, - "value_type": "individual" - }, - "type": "graph", - "xaxis": { - "buckets": null, - "mode": "time", - "name": null, - "show": true, - "values": [] - }, - "yaxes": [ - { - "format": "ms", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - }, - { - "format": "short", - "label": null, - "logBase": 1, - "max": null, - "min": null, - "show": true - } - ], - "yaxis": { - "align": false, - "alignLevel": null - } - }, - { - "aliasColors": {}, - "bars": false, - "dashLength": 10, - "dashes": false, - "datasource": "$ds", - "fieldConfig": { - "defaults": { - "color": {}, - "custom": {}, - "thresholds": { - "mode": "absolute", - "steps": [] - } - }, - "overrides": [] - }, - "fill": 1, - "fillGradient": 0, - "gridPos": { - "h": 10, - "w": 6, - "x": 18, - "y": 46 - }, - "hiddenSeries": false, "id": 35, "legend": { "avg": true, @@ -8484,5 +8318,5 @@ "timezone": "", "title": "Azure / Insights / Data Explorer Clusters", "uid": "8UDB1s3Gk", - "version": 10 + "version": 11 } diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/dashboards/storage.json b/public/app/plugins/datasource/grafana-azure-monitor-datasource/dashboards/storage.json index af4f775a4ac..bc4be8330cd 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/dashboards/storage.json +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/dashboards/storage.json @@ -6426,5 +6426,5 @@ "timezone": "", "title": "Azure / Insights / Storage Accounts", "uid": "3n2E8CrGk", - "version": 28 + "version": 29 } diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/migrateQuery.ts b/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/migrateQuery.ts index ca4ba9e8337..da856ec9496 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/migrateQuery.ts +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/utils/migrateQuery.ts @@ -109,6 +109,10 @@ function migrateResourceUri(query: AzureMonitorQuery): AzureMonitorQuery { return query; } + if (metricDefinition.includes('$') || resourceName.includes('$')) { + return query; + } + const resourceUri = UrlBuilder.buildResourceUri(subscription, resourceGroup, metricDefinition, resourceName); return { diff --git a/public/app/plugins/datasource/graphite/datasource.test.ts b/public/app/plugins/datasource/graphite/datasource.test.ts index 54ce71f387a..b0c271bbdeb 100644 --- a/public/app/plugins/datasource/graphite/datasource.test.ts +++ b/public/app/plugins/datasource/graphite/datasource.test.ts @@ -113,7 +113,7 @@ describe('graphiteDatasource', () => { const query = { panelId: 3, dashboardId: 5, - range: { raw: { from: 'now-1h', to: 'now' } }, + range: { from: dateTime('2022-04-01T00:00:00'), to: dateTime('2022-07-01T00:00:00') }, targets: [{ target: 'prod1.count' }, { target: 'prod2.count' }], maxDataPoints: 500, }; @@ -157,8 +157,8 @@ describe('graphiteDatasource', () => { const params = requestOptions.data.split('&'); expect(params).toContain('target=prod1.count'); expect(params).toContain('target=prod2.count'); - expect(params).toContain('from=-1h'); - expect(params).toContain('until=now'); + expect(params).toContain('from=1648789200'); + expect(params).toContain('until=1656655200'); }); it('should exclude undefined params', () => { diff --git a/public/app/plugins/datasource/graphite/datasource.ts b/public/app/plugins/datasource/graphite/datasource.ts index a83b90b2380..51d0a691a57 100644 --- a/public/app/plugins/datasource/graphite/datasource.ts +++ b/public/app/plugins/datasource/graphite/datasource.ts @@ -179,8 +179,8 @@ export class GraphiteDatasource query(options: DataQueryRequest): Observable { const graphOptions = { - from: this.translateTime(options.range.raw.from, false, options.timezone), - until: this.translateTime(options.range.raw.to, true, options.timezone), + from: this.translateTime(options.range.from, false, options.timezone), + until: this.translateTime(options.range.to, true, options.timezone), targets: options.targets, format: (options as any).format, cacheTimeout: options.cacheTimeout || this.cacheTimeout, diff --git a/public/app/plugins/datasource/influxdb/datasource.ts b/public/app/plugins/datasource/influxdb/datasource.ts index 3001fcd2467..20c72684e2c 100644 --- a/public/app/plugins/datasource/influxdb/datasource.ts +++ b/public/app/plugins/datasource/influxdb/datasource.ts @@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid'; import { AnnotationEvent, + AnnotationQueryRequest, ArrayVector, DataFrame, DataQueryError, @@ -21,7 +22,6 @@ import { TIME_SERIES_TIME_FIELD_NAME, TIME_SERIES_VALUE_FIELD_NAME, TimeSeries, - AnnotationQueryRequest, } from '@grafana/data'; import { BackendDataSourceResponse, @@ -240,7 +240,7 @@ export default class InfluxDatasource extends DataSourceWithBackend { }); describe('Variables should be interpolated correctly', () => { - const templateSrv: any = { replace: jest.fn() }; const instanceSettings: any = {}; - const ds = new InfluxDatasource(instanceSettings, templateSrv); const text = 'interpolationText'; - templateSrv.replace.mockReturnValue(text); + const text2 = 'interpolationText2'; + const textWithoutFormatRegex = 'interpolationText,interpolationText2'; + const textWithFormatRegex = 'interpolationText|interpolationText2'; + const variableMap: Record = { + $interpolationVar: text, + $interpolationVar2: text2, + }; + const templateSrv: any = { + replace: jest.fn((target?: string, scopedVars?: ScopedVars, format?: string | Function): string => { + if (!format) { + return variableMap[target!] || ''; + } + if (format === 'regex') { + return textWithFormatRegex; + } + return textWithoutFormatRegex; + }), + }; + const ds = new InfluxDatasource(instanceSettings, templateSrv); const fluxQuery = { refId: 'x', - query: '$interpolationVar', + query: '$interpolationVar,$interpolationVar2', }; const influxQuery = { @@ -202,7 +219,7 @@ describe('InfluxDataSource', () => { { key: 'cpu', operator: '=~', - value: '/^$interpolationVar$/', + value: '/^$interpolationVar,$interpolationVar2$/', }, ], groupBy: [ @@ -223,20 +240,20 @@ describe('InfluxDataSource', () => { function fluxChecks(query: any) { expect(templateSrv.replace).toBeCalledTimes(1); - expect(query).toBe(text); + expect(query).toBe(textWithFormatRegex); } function influxChecks(query: any) { expect(templateSrv.replace).toBeCalledTimes(10); expect(query.alias).toBe(text); - expect(query.measurement).toBe(text); - expect(query.policy).toBe(text); - expect(query.limit).toBe(text); - expect(query.slimit).toBe(text); + expect(query.measurement).toBe(textWithFormatRegex); + expect(query.policy).toBe(textWithFormatRegex); + expect(query.limit).toBe(textWithFormatRegex); + expect(query.slimit).toBe(textWithFormatRegex); expect(query.tz).toBe(text); - expect(query.tags![0].value).toBe(text); - expect(query.groupBy![0].params![0]).toBe(text); - expect(query.select![0][0].params![0]).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); } describe('when interpolating query variables for dashboard->explore', () => { @@ -244,6 +261,7 @@ describe('InfluxDataSource', () => { ds.isFlux = true; const queries = ds.interpolateVariablesInQueries([fluxQuery], { interpolationVar: { text: text, value: text }, + interpolationVar2: { text: text2, value: text2 }, }); fluxChecks(queries[0].query); }); @@ -252,6 +270,7 @@ describe('InfluxDataSource', () => { ds.isFlux = false; const queries = ds.interpolateVariablesInQueries([influxQuery], { interpolationVar: { text: text, value: text }, + interpolationVar2: { text: text2, value: text2 }, }); influxChecks(queries[0]); }); @@ -260,7 +279,12 @@ describe('InfluxDataSource', () => { describe('when interpolating template variables', () => { it('should apply all template variables with Flux mode', () => { ds.isFlux = true; - const query = ds.applyTemplateVariables(fluxQuery, { interpolationVar: { text: text, value: text } }); + const query = ds.applyTemplateVariables(fluxQuery, { + interpolationVar: { + text: text, + value: text, + }, + }); fluxChecks(query.query); }); @@ -268,7 +292,10 @@ describe('InfluxDataSource', () => { ds.isFlux = false; ds.access = 'proxy'; config.featureToggles.influxdbBackendMigration = true; - const query = ds.applyTemplateVariables(influxQuery, { interpolationVar: { text: text, value: text } }); + const query = ds.applyTemplateVariables(influxQuery, { + interpolationVar: { text: text, value: text }, + interpolationVar2: { text: 'interpolationText2', value: 'interpolationText2' }, + }); influxChecks(query); }); }); diff --git a/public/app/plugins/datasource/jaeger/components/ConfigEditor.tsx b/public/app/plugins/datasource/jaeger/components/ConfigEditor.tsx index 46fcb204394..a4ba733a55a 100644 --- a/public/app/plugins/datasource/jaeger/components/ConfigEditor.tsx +++ b/public/app/plugins/datasource/jaeger/components/ConfigEditor.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; import { config } from '@grafana/runtime'; import { DataSourceHttpSettings } from '@grafana/ui'; +import { SpanBarSettings } from '@jaegertracing/jaeger-ui-components'; import { NodeGraphSettings } from 'app/core/components/NodeGraphSettings'; import { TraceToLogsSettings } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; import { TraceToMetricsSettings } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; @@ -32,6 +33,10 @@ export const ConfigEditor: React.FC = ({ options, onOptionsChange }) => {
    + +
    + +
    ); }; diff --git a/public/app/plugins/datasource/jaeger/datasource.ts b/public/app/plugins/datasource/jaeger/datasource.ts index ec991e7ed63..bc8be4bc02a 100644 --- a/public/app/plugins/datasource/jaeger/datasource.ts +++ b/public/app/plugins/datasource/jaeger/datasource.ts @@ -15,6 +15,7 @@ import { ScopedVars, } from '@grafana/data'; import { BackendSrvRequest, getBackendSrv, getTemplateSrv, TemplateSrv } from '@grafana/runtime'; +import { SpanBarOptions } from '@jaegertracing/jaeger-ui-components'; import { NodeGraphOptions } from 'app/core/components/NodeGraphSettings'; import { serializeParams } from 'app/core/utils/fetch'; import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv'; @@ -32,6 +33,7 @@ export interface JaegerJsonData extends DataSourceJsonData { export class JaegerDatasource extends DataSourceApi { uploadedJson: string | ArrayBuffer | null = null; nodeGraph?: NodeGraphOptions; + spanBar?: SpanBarOptions; constructor( private instanceSettings: DataSourceInstanceSettings, private readonly timeSrv: TimeSrv = getTimeSrv(), diff --git a/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationHeader.tsx b/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationHeader.tsx index 7ca2d4630d4..f6acf54caae 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationHeader.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/shared/OperationHeader.tsx @@ -79,7 +79,16 @@ export const OperationHeader = React.memo( if (value.value) { // Operation should exist if it is selectable const newDef = queryModeller.getOperationDef(value.value.id)!; - let changedOp = { ...operation, id: 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); } }} diff --git a/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx b/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx index 98140dee30f..ee2763991d5 100644 --- a/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx +++ b/public/app/plugins/datasource/tempo/configuration/ConfigEditor.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; import { config } from '@grafana/runtime'; import { DataSourceHttpSettings } from '@grafana/ui'; +import { SpanBarSettings } from '@jaegertracing/jaeger-ui-components'; import { NodeGraphSettings } from 'app/core/components/NodeGraphSettings'; import { TraceToLogsSettings } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; import { TraceToMetricsSettings } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; @@ -50,6 +51,10 @@ export const ConfigEditor: React.FC = ({ options, onOptionsChange }) => {
    + +
    + +
    ); }; diff --git a/public/app/plugins/datasource/tempo/datasource.ts b/public/app/plugins/datasource/tempo/datasource.ts index 0c70376010a..dec0213a80d 100644 --- a/public/app/plugins/datasource/tempo/datasource.ts +++ b/public/app/plugins/datasource/tempo/datasource.ts @@ -24,6 +24,7 @@ import { TemplateSrv, getTemplateSrv, } from '@grafana/runtime'; +import { SpanBarOptions } from '@jaegertracing/jaeger-ui-components'; import { NodeGraphOptions } from 'app/core/components/NodeGraphSettings'; import { TraceToLogsOptions } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; import { serializeParams } from 'app/core/utils/fetch'; @@ -65,6 +66,9 @@ export interface TempoJsonData extends DataSourceJsonData { lokiSearch?: { datasourceUid?: string; }; + spanBar?: { + tag: string; + }; } export interface TempoQuery extends DataQuery { @@ -105,6 +109,7 @@ export class TempoDatasource extends DataSourceWithBackend, diff --git a/public/app/plugins/datasource/zipkin/ConfigEditor.tsx b/public/app/plugins/datasource/zipkin/ConfigEditor.tsx index fc876e5a91e..c864748c688 100644 --- a/public/app/plugins/datasource/zipkin/ConfigEditor.tsx +++ b/public/app/plugins/datasource/zipkin/ConfigEditor.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { DataSourcePluginOptionsEditorProps } from '@grafana/data'; import { config } from '@grafana/runtime'; import { DataSourceHttpSettings } from '@grafana/ui'; +import { SpanBarSettings } from '@jaegertracing/jaeger-ui-components'; import { NodeGraphSettings } from 'app/core/components/NodeGraphSettings'; import { TraceToLogsSettings } from 'app/core/components/TraceToLogs/TraceToLogsSettings'; import { TraceToMetricsSettings } from 'app/core/components/TraceToMetrics/TraceToMetricsSettings'; @@ -32,6 +33,10 @@ export const ConfigEditor: React.FC = ({ options, onOptionsChange }) => {
    + +
    + +
    ); }; diff --git a/public/app/plugins/datasource/zipkin/datasource.ts b/public/app/plugins/datasource/zipkin/datasource.ts index 81af519b902..4c088c4548c 100644 --- a/public/app/plugins/datasource/zipkin/datasource.ts +++ b/public/app/plugins/datasource/zipkin/datasource.ts @@ -11,6 +11,7 @@ import { MutableDataFrame, } from '@grafana/data'; import { BackendSrvRequest, FetchResponse, getBackendSrv } from '@grafana/runtime'; +import { SpanBarOptions } from '@jaegertracing/jaeger-ui-components'; import { NodeGraphOptions } from 'app/core/components/NodeGraphSettings'; import { serializeParams } from '../../../core/utils/fetch'; @@ -27,6 +28,7 @@ export interface ZipkinJsonData extends DataSourceJsonData { export class ZipkinDatasource extends DataSourceApi { uploadedJson: string | ArrayBuffer | null = null; nodeGraph?: NodeGraphOptions; + spanBar?: SpanBarOptions; constructor(private instanceSettings: DataSourceInstanceSettings) { super(instanceSettings); this.nodeGraph = instanceSettings.jsonData.nodeGraph; diff --git a/public/app/plugins/panel/barchart/models.gen.ts b/public/app/plugins/panel/barchart/models.gen.ts index 8068fa61aa3..1653e84f877 100644 --- a/public/app/plugins/panel/barchart/models.gen.ts +++ b/public/app/plugins/panel/barchart/models.gen.ts @@ -45,5 +45,4 @@ export const defaultBarChartFieldConfig: BarChartFieldConfig = { lineWidth: 1, fillOpacity: 80, gradientMode: GraphGradientMode.None, - axisSoftMin: 0, }; diff --git a/public/app/plugins/panel/barchart/utils.ts b/public/app/plugins/panel/barchart/utils.ts index 1a45b42a2bb..9d24655bf16 100644 --- a/public/app/plugins/panel/barchart/utils.ts +++ b/public/app/plugins/panel/barchart/utils.ts @@ -173,6 +173,18 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ const scaleColor = getFieldSeriesColor(field, theme); const seriesColor = scaleColor.color; + // make barcharts start at 0 unless explicitly overridden + let softMin = customConfig.axisSoftMin; + let softMax = customConfig.axisSoftMax; + + if (softMin == null && field.config.min == null) { + softMin = 0; + } + + if (softMax == null && field.config.max == null) { + softMax = 0; + } + builder.addSeries({ scaleKey, pxAlign: true, @@ -187,8 +199,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ thresholds: field.config.thresholds, hardMin: field.config.min, hardMax: field.config.max, - softMin: customConfig.axisSoftMin, - softMax: customConfig.axisSoftMax, + softMin, + softMax, // The following properties are not used in the uPlot config, but are utilized as transport for legend config // PlotLegend currently gets unfiltered DataFrame[], so index must be into that field array, not the prepped frame's which we're iterating here @@ -207,8 +219,8 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ({ scaleKey, min: field.config.min, max: field.config.max, - softMin: customConfig.axisSoftMin, - softMax: customConfig.axisSoftMax, + softMin, + softMax, orientation: vizOrientation.yOri, direction: vizOrientation.yDir, distribution: customConfig.scaleDistribution?.type, diff --git a/public/app/plugins/panel/geomap/GeomapPanel.tsx b/public/app/plugins/panel/geomap/GeomapPanel.tsx index 92203974659..0647d99a707 100644 --- a/public/app/plugins/panel/geomap/GeomapPanel.tsx +++ b/public/app/plugins/panel/geomap/GeomapPanel.tsx @@ -7,11 +7,10 @@ import Attribution from 'ol/control/Attribution'; import ScaleLine from 'ol/control/ScaleLine'; import Zoom from 'ol/control/Zoom'; import { Coordinate } from 'ol/coordinate'; -import { createEmpty, extend, isEmpty } from 'ol/extent'; +import { isEmpty } from 'ol/extent'; import { defaults as interactionDefaults } from 'ol/interaction'; import MouseWheelZoom from 'ol/interaction/MouseWheelZoom'; import BaseLayer from 'ol/layer/Base'; -import VectorLayer from 'ol/layer/Vector'; import { fromLonLat, toLonLat } from 'ol/proj'; import React, { Component, ReactNode } from 'react'; import { Subject, Subscription } from 'rxjs'; @@ -40,6 +39,7 @@ import { getGlobalStyles } from './globalStyles'; import { defaultMarkersConfig, MARKERS_LAYER_ID } from './layers/data/markersLayer'; import { DEFAULT_BASEMAP_CONFIG, geomapLayerRegistry } from './layers/registry'; import { ControlsOptions, GeomapPanelOptions, MapLayerState, MapViewConfig, TooltipMode } from './types'; +import { getLayersExtent } from './utils/getLayersExtent'; import { centerPointRegistry, MapCenterID } from './view'; // Allows multiple panels to share the same view instance @@ -590,11 +590,7 @@ export class GeomapPanel extends Component { if (v.id === MapCenterID.Coordinates) { coord = [config.lon ?? 0, config.lat ?? 0]; } else if (v.id === MapCenterID.Fit) { - var extent = layers - .getArray() - .filter((l) => l instanceof VectorLayer) - .map((l) => (l as VectorLayer).getSource().getExtent() ?? []) - .reduce(extend, createEmpty()); + const extent = getLayersExtent(layers); if (!isEmpty(extent)) { view.fit(extent, { padding: [30, 30, 30, 30], diff --git a/public/app/plugins/panel/geomap/layers/data/routeLayer.tsx b/public/app/plugins/panel/geomap/layers/data/routeLayer.tsx index 29f4840eed4..23ad642e9f0 100644 --- a/public/app/plugins/panel/geomap/layers/data/routeLayer.tsx +++ b/public/app/plugins/panel/geomap/layers/data/routeLayer.tsx @@ -6,10 +6,15 @@ import { FrameGeometrySourceMode, PluginState, EventBus, + DataHoverEvent, + DataHoverClearEvent, + DataFrame, + TIME_SERIES_TIME_FIELD_NAME, } from '@grafana/data'; import Map from 'ol/Map'; import { FeatureLike } from 'ol/Feature'; -import { getLocationMatchers } from 'app/features/geo/utils/location'; +import { Subscription, throttleTime } from 'rxjs'; +import { getGeometryField, getLocationMatchers } from 'app/features/geo/utils/location'; import { getColorDimension } from 'app/features/dimensions'; import { defaultStyleConfig, StyleConfig, StyleDimensions } from '../../style/types'; import { StyleEditor } from './StyleEditor'; @@ -18,6 +23,11 @@ import VectorLayer from 'ol/layer/Vector'; import { isNumber } from 'lodash'; import { routeStyle } from '../../style/markers'; import { FrameVectorSource } from 'app/features/geo/utils/frameVectorSource'; +import { Group as LayerGroup } from 'ol/layer'; +import VectorSource from 'ol/source/Vector'; +import { Fill, Stroke, Style, Circle } from 'ol/style'; +import Feature from 'ol/Feature'; +import { alpha } from '@grafana/data/src/themes/colorManipulator'; // Configuration options for Circle overlays export interface RouteConfig { @@ -70,9 +80,7 @@ export const routeLayer: MapLayerRegistryItem = { const style = await getStyleConfigState(config.style); const location = await getLocationMatchers(options.location); const source = new FrameVectorSource(location); - const vectorLayer = new VectorLayer({ - source, - }); + const vectorLayer = new VectorLayer({ source }); if (!style.fields) { // Set a global style @@ -94,8 +102,70 @@ export const routeLayer: MapLayerRegistryItem = { }); } + // Crosshair layer + const crosshairFeature = new Feature({}); + const crosshairRadius = (style.base.lineWidth || 6) + 2; + const crosshairStyle = new Style({ + image: new Circle({ + radius: crosshairRadius, + stroke: new Stroke({ + color: alpha(style.base.color, 0.4), + width: crosshairRadius + 2 + }), + fill: new Fill({color: style.base.color}), + }) + }); + + const crosshairLayer = new VectorLayer({ + source: new VectorSource({ + features: [crosshairFeature], + }), + style: crosshairStyle, + }); + + const layer = new LayerGroup({ + layers: [vectorLayer, crosshairLayer] + }); + + // Crosshair sharing subscriptions + const subscriptions = new Subscription(); + + subscriptions.add( + eventBus + .getStream(DataHoverEvent) + .pipe(throttleTime(8)) + .subscribe({ + next: (event) => { + const feature = source.getFeatures()[0]; + const frame = feature?.get('frame') as DataFrame; + const time = event.payload?.point?.time as number; + if (frame && time) { + const timeField = frame.fields.find((f) => f.name === TIME_SERIES_TIME_FIELD_NAME); + if (timeField) { + const timestamps: number[] = timeField.values.toArray(); + const pointIdx = findNearestTimeIndex(timestamps, time); + if (pointIdx !== null) { + const out = getGeometryField(frame, location); + if (out.field) { + crosshairFeature.setGeometry(out.field.values.get(pointIdx)); + crosshairFeature.setStyle(crosshairStyle); + } + } + } + } + }, + }) + ); + + subscriptions.add( + eventBus.subscribe(DataHoverClearEvent, (event) => { + crosshairFeature.setStyle(new Style({})); + }) + ); + return { - init: () => vectorLayer, + init: () => layer, + dispose: () => subscriptions.unsubscribe(), update: (data: PanelData) => { if (!data.series?.length) { return; // ignore empty @@ -145,3 +215,34 @@ export const routeLayer: MapLayerRegistryItem = { // fill in the default values defaultOptions, }; + +function findNearestTimeIndex(timestamps: number[], time: number): number | null { + if (timestamps.length === 0) { + return null; + } else if (timestamps.length === 1) { + return 0; + } + const lastIdx = timestamps.length - 1; + if (time < timestamps[0]) { + return 0; + } else if (time > timestamps[lastIdx]) { + return lastIdx; + } + + const probableIdx = Math.abs(Math.round(lastIdx * (time - timestamps[0]) / (timestamps[lastIdx] - timestamps[0]))); + if (time < timestamps[probableIdx]) { + for (let i = probableIdx; i > 0; i--) { + if (time > timestamps[i]) { + return i; + } + } + return 0; + } else { + for (let i = probableIdx; i < lastIdx; i++) { + if (time < timestamps[i]) { + return i; + } + } + return lastIdx; + } +} diff --git a/public/app/plugins/panel/geomap/utils/getLayersExtent.ts b/public/app/plugins/panel/geomap/utils/getLayersExtent.ts new file mode 100644 index 00000000000..fe73c991a6a --- /dev/null +++ b/public/app/plugins/panel/geomap/utils/getLayersExtent.ts @@ -0,0 +1,33 @@ +import { Collection } from 'ol'; +import { createEmpty, extend, Extent } from 'ol/extent'; +import BaseLayer from 'ol/layer/Base'; +import LayerGroup from 'ol/layer/Group'; +import VectorLayer from 'ol/layer/Vector'; + +export function getLayersExtent(layers: Collection): Extent { + return layers + .getArray() + .filter((l) => l instanceof VectorLayer || l instanceof LayerGroup) + .flatMap((l) => { + if (l instanceof LayerGroup) { + return getLayerGroupExtent(l); + } else if (l instanceof VectorLayer) { + return l.getSource().getExtent() ?? []; + } + }) + .reduce(extend, createEmpty()); +} + +export function getLayerGroupExtent(lg: LayerGroup) { + return lg + .getLayers() + .getArray() + .filter((l) => l instanceof VectorLayer) + .map((l) => { + if (l instanceof VectorLayer) { + return l.getSource().getExtent() ?? []; + } else { + return []; + } + }); +} diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index c3a59ffeb04..b05e107eb9c 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -33,7 +33,6 @@ export function getAppRoutes(): RouteDescriptor[] { }, { path: '/d/:uid/:slug?', - navId: 'dashboards', pageClass: 'page-dashboard', routeName: DashboardRoutes.Normal, component: SafeDynamicImport( @@ -91,7 +90,6 @@ export function getAppRoutes(): RouteDescriptor[] { }, { path: '/datasources', - navId: 'datasources', component: SafeDynamicImport( () => import(/* webpackChunkName: "DataSourcesListPage"*/ 'app/features/datasources/DataSourcesListPage') ), @@ -193,7 +191,6 @@ export function getAppRoutes(): RouteDescriptor[] { }, { path: '/org/users', - navId: 'users', component: SafeDynamicImport( () => import(/* webpackChunkName: "UsersListPage" */ 'app/features/users/UsersListPage') ), @@ -206,7 +203,6 @@ export function getAppRoutes(): RouteDescriptor[] { }, { path: '/org/apikeys', - navId: 'apikeys', roles: () => contextSrv.evaluatePermission(() => ['Admin'], [AccessControlAction.ActionAPIKeysRead]), component: SafeDynamicImport( () => import(/* webpackChunkName: "ApiKeysPage" */ 'app/features/api-keys/ApiKeysPage') @@ -235,7 +231,6 @@ export function getAppRoutes(): RouteDescriptor[] { }, { path: '/org/teams', - navId: 'teams', roles: () => contextSrv.evaluatePermission( () => (config.editorsCanAdmin ? ['Editor', 'Admin'] : ['Admin']), @@ -306,6 +301,13 @@ export function getAppRoutes(): RouteDescriptor[] { () => import(/* webpackChunkName: "AdminEditOrgPage" */ 'app/features/admin/AdminEditOrgPage') ), }, + { + path: '/admin/storage/:path*', + roles: () => ['Admin'], + component: SafeDynamicImport( + () => import(/* webpackChunkName: "StoragePage" */ 'app/features/storage/StoragePage') + ), + }, { path: '/admin/stats', component: SafeDynamicImport( @@ -321,7 +323,7 @@ export function getAppRoutes(): RouteDescriptor[] { path: '/login', component: LoginPage, pageClass: 'login-page sidemenu-hidden', - navHidden: true, + chromeless: true, }, { path: '/invite/:code', @@ -329,7 +331,7 @@ export function getAppRoutes(): RouteDescriptor[] { () => import(/* webpackChunkName: "SignupInvited" */ 'app/features/invites/SignupInvited') ), pageClass: 'sidemenu-hidden', - navHidden: true, + chromeless: true, }, { path: '/verify', @@ -339,7 +341,7 @@ export function getAppRoutes(): RouteDescriptor[] { () => import(/* webpackChunkName "VerifyEmailPage"*/ 'app/core/components/Signup/VerifyEmailPage') ), pageClass: 'login-page sidemenu-hidden', - navHidden: true, + chromeless: true, }, { path: '/signup', @@ -347,12 +349,12 @@ export function getAppRoutes(): RouteDescriptor[] { ? () => : SafeDynamicImport(() => import(/* webpackChunkName "SignupPage"*/ 'app/core/components/Signup/SignupPage')), pageClass: 'sidemenu-hidden login-page', - navHidden: true, + chromeless: true, }, { path: '/user/password/send-reset-email', pageClass: 'sidemenu-hidden', - navHidden: true, + chromeless: true, component: SafeDynamicImport( () => import(/* webpackChunkName: "SendResetMailPage" */ 'app/core/components/ForgottenPassword/SendResetMailPage') @@ -367,7 +369,7 @@ export function getAppRoutes(): RouteDescriptor[] { ) ), pageClass: 'sidemenu-hidden login-page', - navHidden: true, + chromeless: true, }, { path: '/dashboard/snapshots', @@ -437,6 +439,7 @@ export function getAppRoutes(): RouteDescriptor[] { () => import(/* webpackChunkName: "NotificationsPage"*/ 'app/features/notifications/NotificationsPage') ), }, + ...getDynamicDashboardRoutes(), ...getPluginCatalogRoutes(), ...getLiveRoutes(), ...getAlertingRoutes(), @@ -452,3 +455,19 @@ export function getAppRoutes(): RouteDescriptor[] { // ...playlistRoutes, ]; } + +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/:name', + component: SafeDynamicImport(() => import(/* webpackChunkName: "scenes"*/ 'app/features/scenes/ScenePage')), + }, + ]; +} diff --git a/yarn.lock b/yarn.lock index b810c4d4a02..e930782d024 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7221,14 +7221,14 @@ __metadata: linkType: hard "@npmcli/metavuln-calculator@npm:^3.0.1": - version: 3.1.0 - resolution: "@npmcli/metavuln-calculator@npm:3.1.0" + version: 3.1.1 + resolution: "@npmcli/metavuln-calculator@npm:3.1.1" dependencies: cacache: ^16.0.0 json-parse-even-better-errors: ^2.3.1 pacote: ^13.0.3 semver: ^7.3.5 - checksum: 39fb474e239d3f221178f0c2f6089cd4a2fce8183343b7f52f8f9fe0b3cb0a98b386b15c9afe63a0b0dc2ae5302497d00eb2de2f4b3431953dbf05e69d613c9a + checksum: dc9846fdb82a1f4274ff8943f81452c75615bd9bca523c862956ea2c32e18c5a4be5572e169104d3a0eb262b7ede72c8dbbc202a4ab3b3f4946fa55f226dcc64 languageName: node linkType: hard @@ -7297,14 +7297,15 @@ __metadata: linkType: hard "@npmcli/run-script@npm:^4.1.0": - version: 4.1.3 - resolution: "@npmcli/run-script@npm:4.1.3" + version: 4.1.5 + resolution: "@npmcli/run-script@npm:4.1.5" dependencies: "@npmcli/node-gyp": ^2.0.0 "@npmcli/promise-spawn": ^3.0.0 node-gyp: ^9.0.0 read-package-json-fast: ^2.0.3 - checksum: 62fb1a0e126561514d9202db5fa25ede128fc9ab8d15a307e232137ec89882e9196ba08eadc0635ea49adb308300b197d83eb3cb7c6edbc26ec6f3999cc1e7bb + which: ^2.0.2 + checksum: a71736c628c747edd3275861a60bf8d76f8e954d0ab9561f0364d07f7a24ebf81159d0826a2c85705eae6391ffae6328bae6ca581264b5b3f3ca029510201387 languageName: node linkType: hard @@ -7318,17 +7319,17 @@ __metadata: linkType: hard "@octokit/core@npm:^3.5.1": - version: 3.5.1 - resolution: "@octokit/core@npm:3.5.1" + version: 3.6.0 + resolution: "@octokit/core@npm:3.6.0" dependencies: "@octokit/auth-token": ^2.4.4 "@octokit/graphql": ^4.5.8 - "@octokit/request": ^5.6.0 + "@octokit/request": ^5.6.3 "@octokit/request-error": ^2.0.5 "@octokit/types": ^6.0.3 before-after-hook: ^2.2.0 universal-user-agent: ^6.0.0 - checksum: 67179739fc9712b201f2400f132287a2c56a18506e00900bc9d2a3f742b74f1ba69ad998e42f28f3964c0bd1d5478232c1ec7b485c97702b821fbe22b76afa90 + checksum: f81160129037bd8555d47db60cd5381637b7e3602ad70735a7bdf8f3d250c7b7114a666bb12ef7a8746a326a5d72ed30a1b8f8a5a170007f7285c8e217bef1f0 languageName: node linkType: hard @@ -7354,10 +7355,10 @@ __metadata: languageName: node linkType: hard -"@octokit/openapi-types@npm:^11.2.0": - version: 11.2.0 - resolution: "@octokit/openapi-types@npm:11.2.0" - checksum: eb373ea496bc96bf0233505a0916eb38cb193d1829cab935e1cf1fd21839c402a1d835d3c0326290c756c0ed980a64d0ae73ad3c5d5decde9000f0828aa7ff52 +"@octokit/openapi-types@npm:^12.7.0": + version: 12.8.0 + resolution: "@octokit/openapi-types@npm:12.8.0" + checksum: 66058ebb9aabfeafaf61cd205487edd14b8f3eac7c6aa796cd7489fe948657912514d9b02918c1bca9d88a07dbb2deed5763e2ca61a6a8f6503caee32429a7b1 languageName: node linkType: hard @@ -7369,13 +7370,13 @@ __metadata: linkType: hard "@octokit/plugin-paginate-rest@npm:^2.16.8": - version: 2.17.0 - resolution: "@octokit/plugin-paginate-rest@npm:2.17.0" + version: 2.21.1 + resolution: "@octokit/plugin-paginate-rest@npm:2.21.1" dependencies: - "@octokit/types": ^6.34.0 + "@octokit/types": ^6.38.2 peerDependencies: "@octokit/core": ">=2" - checksum: c8753cda6f7ede79d0e9df43a54e56020aa1c9c6887684e0e0d45cb6ee0dcabf460c3e4b8a18edabef711bb269fd826616e99e78dc29fb30d47c210c562603a0 + checksum: 2941d0320ec6891cbb0248afe8f8ba019e2971704c6b975a01b99358c432ed84c2920f52d095fc912616e43a59ef582c1df6b868d59a39e2bf095476306ba6f1 languageName: node linkType: hard @@ -7389,14 +7390,14 @@ __metadata: linkType: hard "@octokit/plugin-rest-endpoint-methods@npm:^5.12.0": - version: 5.13.0 - resolution: "@octokit/plugin-rest-endpoint-methods@npm:5.13.0" + version: 5.16.2 + resolution: "@octokit/plugin-rest-endpoint-methods@npm:5.16.2" dependencies: - "@octokit/types": ^6.34.0 + "@octokit/types": ^6.39.0 deprecation: ^2.3.1 peerDependencies: "@octokit/core": ">=3" - checksum: f331457e4317130adb456b27df2a99609fb54a4dc2da6f87009e567c7325680c901abf18ad08483535bab4ec1c892e4236f4135a2804603aebb12c0698c678c8 + checksum: 30fcc50c335d1093f03573d9fa3a4b7d027fc98b215c43e07e82ee8dabfa0af0cf1b963feb542312ae32d897a2f68dc671577206f30850215517bebedc5a2c73 languageName: node linkType: hard @@ -7411,17 +7412,17 @@ __metadata: languageName: node linkType: hard -"@octokit/request@npm:^5.6.0": - version: 5.6.2 - resolution: "@octokit/request@npm:5.6.2" +"@octokit/request@npm:^5.6.0, @octokit/request@npm:^5.6.3": + version: 5.6.3 + resolution: "@octokit/request@npm:5.6.3" dependencies: "@octokit/endpoint": ^6.0.1 "@octokit/request-error": ^2.1.0 "@octokit/types": ^6.16.1 is-plain-object: ^5.0.0 - node-fetch: ^2.6.1 + node-fetch: ^2.6.7 universal-user-agent: ^6.0.0 - checksum: 51ef3ad244b3d89ffd6d997fa0ed3e13a7a93b4c868ce5c53b0fcc93a654965135528e62d0720ebfeb7dfd586448a4a45d08fd75ba2e170cfa19d37834e49f1f + checksum: c0b4542eb4baaf880d673c758d3e0b5c4a625a4ae30abf40df5548b35f1ff540edaac74625192b1aff42a79ac661e774da4ab7d5505f1cb4ef81239b1e8510c5 languageName: node linkType: hard @@ -7437,12 +7438,12 @@ __metadata: languageName: node linkType: hard -"@octokit/types@npm:^6.0.3, @octokit/types@npm:^6.16.1, @octokit/types@npm:^6.34.0": - version: 6.34.0 - resolution: "@octokit/types@npm:6.34.0" +"@octokit/types@npm:^6.0.3, @octokit/types@npm:^6.16.1, @octokit/types@npm:^6.38.2, @octokit/types@npm:^6.39.0": + version: 6.39.0 + resolution: "@octokit/types@npm:6.39.0" dependencies: - "@octokit/openapi-types": ^11.2.0 - checksum: f122b9aee8f6baddd515e34a0913e73b21d4bc82d6ee59d77a8aaf01b4a02c10867dd013003d087a83dc96db23511893669015af6d30c27cece185e21cf1df89 + "@octokit/openapi-types": ^12.7.0 + checksum: 0e3d55e4bdef41e652a2e99a304cebada90f23a90619c49ab892a76ae908c4281316738453348c9ba30b32250ef5eda0a939c2af3aec5ca808acdf9922da420c languageName: node linkType: hard @@ -8485,13 +8486,6 @@ __metadata: languageName: node linkType: hard -"@sindresorhus/is@npm:^0.14.0": - version: 0.14.0 - resolution: "@sindresorhus/is@npm:0.14.0" - checksum: 971e0441dd44ba3909b467219a5e242da0fc584048db5324cfb8048148fa8dcc9d44d71e3948972c4f6121d24e5da402ef191420d1266a95f713bb6d6e59c98a - languageName: node - linkType: hard - "@sinonjs/commons@npm:^1.6.0, @sinonjs/commons@npm:^1.7.0, @sinonjs/commons@npm:^1.8.3": version: 1.8.3 resolution: "@sinonjs/commons@npm:1.8.3" @@ -10016,15 +10010,6 @@ __metadata: languageName: node linkType: hard -"@szmarczak/http-timer@npm:^1.1.2": - version: 1.1.2 - resolution: "@szmarczak/http-timer@npm:1.1.2" - dependencies: - defer-to-connect: ^1.0.1 - checksum: 4d9158061c5f397c57b4988cde33a163244e4f02df16364f103971957a32886beb104d6180902cbe8b38cb940e234d9f98a4e486200deca621923f62f50a06fe - languageName: node - linkType: hard - "@testing-library/dom@npm:8.13.0, @testing-library/dom@npm:>=7, @testing-library/dom@npm:^8.0.0": version: 8.13.0 resolution: "@testing-library/dom@npm:8.13.0" @@ -11096,15 +11081,6 @@ __metadata: languageName: node linkType: hard -"@types/keyv@npm:^3.1.1": - version: 3.1.3 - resolution: "@types/keyv@npm:3.1.3" - dependencies: - "@types/node": "*" - checksum: b5f8aa592cc21c16d99e69aec0976f12b893b055e4456d90148a610a6b6088e297b2ba5f38f8c8280cef006cfd8f9ec99e069905020882619dc5fc8aa46f5f27 - languageName: node - linkType: hard - "@types/lingui__macro@npm:^3": version: 3.0.0 resolution: "@types/lingui__macro@npm:3.0.0" @@ -11708,15 +11684,6 @@ __metadata: languageName: node linkType: hard -"@types/responselike@npm:^1.0.0": - version: 1.0.0 - resolution: "@types/responselike@npm:1.0.0" - dependencies: - "@types/node": "*" - checksum: e99fc7cc6265407987b30deda54c1c24bb1478803faf6037557a774b2f034c5b097ffd65847daa87e82a61a250d919f35c3588654b0fdaa816906650f596d1b0 - languageName: node - linkType: hard - "@types/retry@npm:^0.12.0": version: 0.12.1 resolution: "@types/retry@npm:0.12.1" @@ -13331,9 +13298,9 @@ __metadata: linkType: hard "ansi-regex@npm:^3.0.0": - version: 3.0.0 - resolution: "ansi-regex@npm:3.0.0" - checksum: 2ad11c416f81c39f5c65eafc88cf1d71aa91d76a2f766e75e457c2a3c43e8a003aadbf2966b61c497aa6a6940a36412486c975b3270cdfc3f413b69826189ec3 + version: 3.0.1 + resolution: "ansi-regex@npm:3.0.1" + checksum: 09daf180c5f59af9850c7ac1bd7fda85ba596cc8cbeb210826e90755f06c818af86d9fa1e6e8322fab2c3b9e9b03f56c537b42241139f824dd75066a1e7257cc languageName: node linkType: hard @@ -13790,11 +13757,11 @@ __metadata: linkType: hard "async@npm:^2.6.2": - version: 2.6.3 - resolution: "async@npm:2.6.3" + version: 2.6.4 + resolution: "async@npm:2.6.4" dependencies: lodash: ^4.17.14 - checksum: 5e5561ff8fca807e88738533d620488ac03a5c43fce6c937451f7e35f943d33ad06c24af3f681a48cca3d2b0002b3118faff0a128dc89438a9bf0226f712c499 + checksum: a52083fb32e1ebe1d63e5c5624038bb30be68ff07a6c8d7dfe35e47c93fc144bd8652cbec869e0ac07d57dde387aa5f1386be3559cdee799cb1f789678d88e19 languageName: node linkType: hard @@ -14554,7 +14521,7 @@ __metadata: languageName: node linkType: hard -"boxen@npm:^5.0.0, boxen@npm:^5.1.2": +"boxen@npm:^5.1.2": version: 5.1.2 resolution: "boxen@npm:5.1.2" dependencies: @@ -15018,21 +14985,6 @@ __metadata: languageName: node linkType: hard -"cacheable-request@npm:^6.0.0": - version: 6.1.0 - resolution: "cacheable-request@npm:6.1.0" - dependencies: - clone-response: ^1.0.2 - get-stream: ^5.1.0 - http-cache-semantics: ^4.0.0 - keyv: ^3.0.0 - lowercase-keys: ^2.0.0 - normalize-url: ^4.1.0 - responselike: ^1.0.2 - checksum: b510b237b18d17e89942e9ee2d2a077cb38db03f12167fd100932dfa8fc963424bfae0bfa1598df4ae16c944a5484e43e03df8f32105b04395ee9495e9e4e9f1 - languageName: node - linkType: hard - "cachedir@npm:^2.3.0": version: 2.3.0 resolution: "cachedir@npm:2.3.0" @@ -15375,7 +15327,7 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:>=3.0.0 <4.0.0, chokidar@npm:^3.4.1, chokidar@npm:^3.4.2, chokidar@npm:^3.5.2": +"chokidar@npm:>=3.0.0 <4.0.0, chokidar@npm:^3.4.1, chokidar@npm:^3.4.2": version: 3.5.2 resolution: "chokidar@npm:3.5.2" dependencies: @@ -15628,15 +15580,6 @@ __metadata: languageName: node linkType: hard -"clone-response@npm:^1.0.2": - version: 1.0.2 - resolution: "clone-response@npm:1.0.2" - dependencies: - mimic-response: ^1.0.0 - checksum: 2d0e61547fc66276e0903be9654ada422515f5a15741691352000d47e8c00c226061221074ce2c0064d12e975e84a8687cfd35d8b405750cb4e772f87b256eda - languageName: node - linkType: hard - "clone@npm:^1.0.2": version: 1.0.4 resolution: "clone@npm:1.0.4" @@ -16061,20 +16004,6 @@ __metadata: languageName: node linkType: hard -"configstore@npm:^5.0.1": - version: 5.0.1 - resolution: "configstore@npm:5.0.1" - dependencies: - dot-prop: ^5.2.0 - graceful-fs: ^4.1.2 - make-dir: ^3.0.0 - unique-string: ^2.0.0 - write-file-atomic: ^3.0.0 - xdg-basedir: ^4.0.0 - checksum: 60ef65d493b63f96e14b11ba7ec072fdbf3d40110a94fb7199d1c287761bdea5c5244e76b2596325f30c1b652213aa75de96ea20afd4a5f82065e61ea090988e - languageName: node - linkType: hard - "connect-history-api-fallback@npm:^1.6.0": version: 1.6.0 resolution: "connect-history-api-fallback@npm:1.6.0" @@ -16168,12 +16097,12 @@ __metadata: linkType: hard "conventional-changelog-writer@npm:^5.0.0": - version: 5.0.0 - resolution: "conventional-changelog-writer@npm:5.0.0" + version: 5.0.1 + resolution: "conventional-changelog-writer@npm:5.0.1" dependencies: conventional-commits-filter: ^2.0.7 dateformat: ^3.0.0 - handlebars: ^4.7.6 + handlebars: ^4.7.7 json-stringify-safe: ^5.0.1 lodash: ^4.17.15 meow: ^8.0.0 @@ -16182,7 +16111,7 @@ __metadata: through2: ^4.0.0 bin: conventional-changelog-writer: cli.js - checksum: c310b949d354688b971f576c92cac77f11540fee56dccb990169e94e4fc42e40245d2c381f826b7d781deb04d4f7e01701cc29bdd1c3d3cdf8817e8b7a80ea18 + checksum: 5c0129db44577f14b1f8de225b62a392a9927ba7fe3422cb21ad71a771b8472bd03badb7c87cb47419913abc3f2ce3759b69f59550cdc6f7a7b0459015b3b44c languageName: node linkType: hard @@ -16197,8 +16126,8 @@ __metadata: linkType: hard "conventional-commits-parser@npm:^3.2.0": - version: 3.2.3 - resolution: "conventional-commits-parser@npm:3.2.3" + version: 3.2.4 + resolution: "conventional-commits-parser@npm:3.2.4" dependencies: JSONStream: ^1.0.4 is-text-path: ^1.0.1 @@ -16208,7 +16137,7 @@ __metadata: through2: ^4.0.0 bin: conventional-commits-parser: cli.js - checksum: 0f57b5cb7cb359eb49e6807cfd82b27cbe9ac30ec580b20ad7e79575561183110532a6c2e6328ce6c4cd05c01458b9bb781f1f6653b14560f7c509b87b0e9ac7 + checksum: 1627ff203bc9586d89e47a7fe63acecf339aba74903b9114e23d28094f79d4e2d6389bf146ae561461dcba8fc42e7bc228165d2b173f15756c43f1d32bc50bfd languageName: node linkType: hard @@ -16579,13 +16508,6 @@ __metadata: languageName: node linkType: hard -"crypto-random-string@npm:^2.0.0": - version: 2.0.0 - resolution: "crypto-random-string@npm:2.0.0" - checksum: 0283879f55e7c16fdceacc181f87a0a65c53bc16ffe1d58b9d19a6277adcd71900d02bb2c4843dd55e78c51e30e89b0fec618a7f170ebcc95b33182c28f05fd6 - languageName: node - linkType: hard - "css-animation@npm:^1.3.2": version: 1.6.1 resolution: "css-animation@npm:1.6.1" @@ -17728,15 +17650,6 @@ __metadata: languageName: node linkType: hard -"decompress-response@npm:^3.3.0": - version: 3.3.0 - resolution: "decompress-response@npm:3.3.0" - dependencies: - mimic-response: ^1.0.0 - checksum: 952552ac3bd7de2fc18015086b09468645c9638d98a551305e485230ada278c039c91116e946d07894b39ee53c0f0d5b6473f25a224029344354513b412d7380 - languageName: node - linkType: hard - "dedent@npm:^0.7.0": version: 0.7.0 resolution: "dedent@npm:0.7.0" @@ -17744,13 +17657,6 @@ __metadata: languageName: node linkType: hard -"deep-extend@npm:^0.6.0": - version: 0.6.0 - resolution: "deep-extend@npm:0.6.0" - checksum: 7be7e5a8d468d6b10e6a67c3de828f55001b6eb515d014f7aeb9066ce36bd5717161eb47d6a0f7bed8a9083935b465bc163ee2581c8b128d29bf61092fdf57a7 - languageName: node - linkType: hard - "deep-freeze@npm:^0.0.1": version: 0.0.1 resolution: "deep-freeze@npm:0.0.1" @@ -17797,13 +17703,6 @@ __metadata: languageName: node linkType: hard -"defer-to-connect@npm:^1.0.1": - version: 1.1.3 - resolution: "defer-to-connect@npm:1.1.3" - checksum: 9491b301dcfa04956f989481ba7a43c2231044206269eb4ab64a52d6639ee15b1252262a789eb4239fb46ab63e44d4e408641bae8e0793d640aee55398cb3930 - languageName: node - linkType: hard - "define-lazy-prop@npm:^2.0.0": version: 2.0.0 resolution: "define-lazy-prop@npm:2.0.0" @@ -17971,12 +17870,12 @@ __metadata: linkType: hard "dezalgo@npm:^1.0.0": - version: 1.0.3 - resolution: "dezalgo@npm:1.0.3" + version: 1.0.4 + resolution: "dezalgo@npm:1.0.4" dependencies: asap: ^2.0.0 wrappy: 1 - checksum: 8b26238db91423b2702a7a6d9629d0019c37c415e7b6e75d4b3e8d27e9464e21cac3618dd145f4d4ee96c70cc6ff034227b5b8a0e9c09015a8bdbe6dace3cfb9 + checksum: 895389c6aead740d2ab5da4d3466d20fa30f738010a4d3f4dcccc9fc645ca31c9d10b7e1804ae489b1eb02c7986f9f1f34ba132d409b043082a86d9a4e745624 languageName: node linkType: hard @@ -18253,7 +18152,7 @@ __metadata: languageName: node linkType: hard -"dot-prop@npm:^5.1.0, dot-prop@npm:^5.2.0": +"dot-prop@npm:^5.1.0": version: 5.3.0 resolution: "dot-prop@npm:5.3.0" dependencies: @@ -18300,13 +18199,6 @@ __metadata: languageName: node linkType: hard -"duplexer3@npm:^0.1.4": - version: 0.1.4 - resolution: "duplexer3@npm:0.1.4" - checksum: c2fd6969314607d23439c583699aaa43c4100d66b3e161df55dccd731acc57d5c81a64bb4f250805fbe434ddb1d2623fee2386fb890f5886ca1298690ec53415 - languageName: node - linkType: hard - "duplexer@npm:^0.1.1, duplexer@npm:^0.1.2": version: 0.1.2 resolution: "duplexer@npm:0.1.2" @@ -18800,13 +18692,13 @@ __metadata: linkType: hard "es5-ext@npm:^0.10.35, es5-ext@npm:^0.10.50": - version: 0.10.53 - resolution: "es5-ext@npm:0.10.53" + version: 0.10.61 + resolution: "es5-ext@npm:0.10.61" dependencies: - es6-iterator: ~2.0.3 - es6-symbol: ~3.1.3 - next-tick: ~1.0.0 - checksum: 24ec22369260cf98605cb2f51eae9d7df5dc621bc5d3b311f6f5c3d0fcdb7bafae888270f3083ee6e9af27350a5ea49f1fe2dd6406a9017247ca40f091f529b2 + es6-iterator: ^2.0.3 + es6-symbol: ^3.1.3 + next-tick: ^1.1.0 + checksum: 2f2034e91e77fe247d94f0fd13a94bcf113273b7cc4650794d6795e377267ffb2425d3a891bd8c4d9c8b990e16e17dd7c28f12dbd3fa4b0909d0874892f491bf languageName: node linkType: hard @@ -18817,7 +18709,7 @@ __metadata: languageName: node linkType: hard -"es6-iterator@npm:~2.0.3": +"es6-iterator@npm:^2.0.3": version: 2.0.3 resolution: "es6-iterator@npm:2.0.3" dependencies: @@ -18842,7 +18734,7 @@ __metadata: languageName: node linkType: hard -"es6-symbol@npm:^3.1.1, es6-symbol@npm:~3.1.3": +"es6-symbol@npm:^3.1.1, es6-symbol@npm:^3.1.3": version: 3.1.3 resolution: "es6-symbol@npm:3.1.3" dependencies: @@ -18859,13 +18751,6 @@ __metadata: languageName: node linkType: hard -"escape-goat@npm:^2.0.0": - version: 2.1.1 - resolution: "escape-goat@npm:2.1.1" - checksum: ce05c70c20dd7007b60d2d644b625da5412325fdb57acf671ba06cb2ab3cd6789e2087026921a05b665b0a03fadee2955e7fc0b9a67da15a6551a980b260eba7 - languageName: node - linkType: hard - "escape-html@npm:^1.0.3, escape-html@npm:~1.0.3": version: 1.0.3 resolution: "escape-html@npm:1.0.3" @@ -20074,13 +19959,13 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.0.0": - version: 1.14.4 - resolution: "follow-redirects@npm:1.14.4" +"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.14.8": + version: 1.14.8 + resolution: "follow-redirects@npm:1.14.8" peerDependenciesMeta: debug: optional: true - checksum: d4ce74cf5c6f363168b97e706b914eb9ffb6bf4d4c6d8f8330b93088d9b90e566611ddbcf0e42c8ed5fd17598dfeda1d19230d3e9d6d6c6b4d1c10ec3a0b99be + checksum: 40c67899c2e3149a27e8b6498a338ff27f39fe138fde8d7f0756cb44b073ba0bfec3d52af28f20c5bdd67263d564d0d8d7b5efefd431de95c18c42f7b4aef457 languageName: node linkType: hard @@ -20094,16 +19979,6 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.14.8": - version: 1.14.8 - resolution: "follow-redirects@npm:1.14.8" - peerDependenciesMeta: - debug: - optional: true - checksum: 40c67899c2e3149a27e8b6498a338ff27f39fe138fde8d7f0756cb44b073ba0bfec3d52af28f20c5bdd67263d564d0d8d7b5efefd431de95c18c42f7b4aef457 - languageName: node - linkType: hard - "follow-redirects@npm:^1.14.9": version: 1.15.0 resolution: "follow-redirects@npm:1.15.0" @@ -20679,7 +20554,7 @@ __metadata: languageName: node linkType: hard -"get-stream@npm:^4.0.0, get-stream@npm:^4.1.0": +"get-stream@npm:^4.0.0": version: 4.1.0 resolution: "get-stream@npm:4.1.0" dependencies: @@ -20758,8 +20633,8 @@ __metadata: linkType: hard "git-raw-commits@npm:^2.0.8": - version: 2.0.10 - resolution: "git-raw-commits@npm:2.0.10" + version: 2.0.11 + resolution: "git-raw-commits@npm:2.0.11" dependencies: dargs: ^7.0.0 lodash: ^4.17.15 @@ -20768,7 +20643,7 @@ __metadata: through2: ^4.0.0 bin: git-raw-commits: cli.js - checksum: 66e2d7b4cdeff946ac639e1bba37f5dcbd9f5c9245348b31e027e4529f6b6733d23f75768d285d5f29c1f08d3485705a4932300a81a45b77b660fe3ce6089c29 + checksum: c178af43633684106179793b6e3473e1d2bb50bb41d04e2e285ea4eef342ca4090fee6bc8a737552fde879d22346c90de5c49f18c719a0f38d4c934f258a0f79 languageName: node linkType: hard @@ -21049,25 +20924,6 @@ __metadata: languageName: node linkType: hard -"got@npm:^9.6.0": - version: 9.6.0 - resolution: "got@npm:9.6.0" - dependencies: - "@sindresorhus/is": ^0.14.0 - "@szmarczak/http-timer": ^1.1.2 - cacheable-request: ^6.0.0 - decompress-response: ^3.3.0 - duplexer3: ^0.1.4 - get-stream: ^4.1.0 - lowercase-keys: ^1.0.1 - mimic-response: ^1.0.1 - p-cancelable: ^1.0.0 - to-readable-stream: ^1.0.0 - url-parse-lax: ^3.0.0 - checksum: 941807bd9704bacf5eb401f0cc1212ffa1f67c6642f2d028fd75900471c221b1da2b8527f4553d2558f3faeda62ea1cf31665f8b002c6137f5de8732f07370b0 - languageName: node - linkType: hard - "graceful-fs@npm:^4.1.11, graceful-fs@npm:^4.1.15, graceful-fs@npm:^4.1.2, graceful-fs@npm:^4.1.6, graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": version: 4.2.8 resolution: "graceful-fs@npm:4.2.8" @@ -21318,7 +21174,6 @@ __metadata: mutationobserver-shim: 0.3.7 ngtemplate-loader: 2.1.0 node-notifier: 10.0.1 - nodemon: 2.0.16 ol: 6.14.1 ol-ext: ^3.2.25 papaparse: 5.3.2 @@ -21440,7 +21295,7 @@ __metadata: languageName: node linkType: hard -"handlebars@npm:^4.7.6, handlebars@npm:^4.7.7": +"handlebars@npm:^4.7.7": version: 4.7.7 resolution: "handlebars@npm:4.7.7" dependencies: @@ -21581,13 +21436,6 @@ __metadata: languageName: node linkType: hard -"has-yarn@npm:^2.1.0": - version: 2.1.0 - resolution: "has-yarn@npm:2.1.0" - checksum: 5eb1d0bb8518103d7da24532bdbc7124ffc6d367b5d3c10840b508116f2f1bcbcf10fd3ba843ff6e2e991bdf9969fd862d42b2ed58aade88343326c950b7e7f7 - languageName: node - linkType: hard - "has@npm:^1.0.0, has@npm:^1.0.3": version: 1.0.3 resolution: "has@npm:1.0.3" @@ -21780,7 +21628,16 @@ __metadata: languageName: node linkType: hard -"hosted-git-info@npm:^4.0.0, hosted-git-info@npm:^4.0.1": +"hosted-git-info@npm:^4.0.0": + version: 4.1.0 + resolution: "hosted-git-info@npm:4.1.0" + dependencies: + lru-cache: ^6.0.0 + checksum: c3f87b3c2f7eb8c2748c8f49c0c2517c9a95f35d26f4bf54b2a8cba05d2e668f3753548b6ea366b18ec8dadb4e12066e19fa382a01496b0ffa0497eb23cbe461 + languageName: node + linkType: hard + +"hosted-git-info@npm:^4.0.1": version: 4.0.2 resolution: "hosted-git-info@npm:4.0.2" dependencies: @@ -21975,7 +21832,7 @@ __metadata: languageName: node linkType: hard -"http-cache-semantics@npm:^4.0.0, http-cache-semantics@npm:^4.1.0": +"http-cache-semantics@npm:^4.1.0": version: 4.1.0 resolution: "http-cache-semantics@npm:4.1.0" checksum: 974de94a81c5474be07f269f9fd8383e92ebb5a448208223bfb39e172a9dbc26feff250192ecc23b9593b3f92098e010406b0f24bd4d588d631f80214648ed42 @@ -22269,13 +22126,6 @@ __metadata: languageName: node linkType: hard -"ignore-by-default@npm:^1.0.1": - version: 1.0.1 - resolution: "ignore-by-default@npm:1.0.1" - checksum: 441509147b3615e0365e407a3c18e189f78c07af08564176c680be1fabc94b6c789cad1342ad887175d4ecd5225de86f73d376cec8e06b42fd9b429505ffcf8a - languageName: node - linkType: hard - "ignore-walk@npm:^3.0.3": version: 3.0.4 resolution: "ignore-walk@npm:3.0.4" @@ -22369,13 +22219,6 @@ __metadata: languageName: node linkType: hard -"import-lazy@npm:^2.1.0": - version: 2.1.0 - resolution: "import-lazy@npm:2.1.0" - checksum: 05294f3b9dd4971d3a996f0d2f176410fb6745d491d6e73376429189f5c1c3d290548116b2960a7cf3e89c20cdf11431739d1d2d8c54b84061980795010e803a - languageName: node - linkType: hard - "import-lazy@npm:^4.0.0, import-lazy@npm:~4.0.0": version: 4.0.0 resolution: "import-lazy@npm:4.0.0" @@ -22461,7 +22304,7 @@ __metadata: languageName: node linkType: hard -"ini@npm:^1.3.2, ini@npm:^1.3.4, ini@npm:^1.3.5, ini@npm:~1.3.0": +"ini@npm:^1.3.2, ini@npm:^1.3.4, ini@npm:^1.3.5": version: 1.3.8 resolution: "ini@npm:1.3.8" checksum: dfd98b0ca3a4fc1e323e38a6c8eb8936e31a97a918d3b377649ea15bdb15d481207a0dda1021efbd86b464cae29a0d33c1d7dcaf6c5672bee17fa849bc50a1b3 @@ -22965,7 +22808,7 @@ __metadata: languageName: node linkType: hard -"is-installed-globally@npm:^0.4.0, is-installed-globally@npm:~0.4.0": +"is-installed-globally@npm:~0.4.0": version: 0.4.0 resolution: "is-installed-globally@npm:0.4.0" dependencies: @@ -23027,13 +22870,6 @@ __metadata: languageName: node linkType: hard -"is-npm@npm:^5.0.0": - version: 5.0.0 - resolution: "is-npm@npm:5.0.0" - checksum: 9baff02b0c69a3d3c79b162cb2f9e67fb40ef6d172c16601b2e2471c21e9a4fa1fc9885a308d7bc6f3a3cd2a324c27fa0bf284c133c3349bb22571ab70d041cc - languageName: node - linkType: hard - "is-number-object@npm:^1.0.4": version: 1.0.6 resolution: "is-number-object@npm:1.0.6" @@ -23188,11 +23024,11 @@ __metadata: linkType: hard "is-ssh@npm:^1.3.0": - version: 1.3.3 - resolution: "is-ssh@npm:1.3.3" + version: 1.4.0 + resolution: "is-ssh@npm:1.4.0" dependencies: - protocols: ^1.1.0 - checksum: 7a751facad3c61abf080eefe4f5df488d37f690ac2b130a8012001ecee4d7991306561bcb25896894d19268ea0512b20497f243e74d21c5901187a8f55f1c08c + protocols: ^2.0.1 + checksum: 75eaa17b538bee24b661fbeb0f140226ac77e904a6039f787bea418431e2162f1f9c4c4ccad3bd169e036cd701cc631406e8c505d9fa7e20164e74b47f86f40f languageName: node linkType: hard @@ -23340,13 +23176,6 @@ __metadata: languageName: node linkType: hard -"is-yarn-global@npm:^0.3.0": - version: 0.3.0 - resolution: "is-yarn-global@npm:0.3.0" - checksum: bca013d65fee2862024c9fbb3ba13720ffca2fe750095174c1c80922fdda16402b5c233f5ac9e265bc12ecb5446e7b7f519a32d9541788f01d4d44e24d2bf481 - languageName: node - linkType: hard - "isarray@npm:0.0.1": version: 0.0.1 resolution: "isarray@npm:0.0.1" @@ -24868,13 +24697,6 @@ __metadata: languageName: node linkType: hard -"json-buffer@npm:3.0.0": - version: 3.0.0 - resolution: "json-buffer@npm:3.0.0" - checksum: 0cecacb8025370686a916069a2ff81f7d55167421b6aa7270ee74e244012650dd6bce22b0852202ea7ff8624fce50ff0ec1bdf95914ccb4553426e290d5a63fa - languageName: node - linkType: hard - "json-markup@npm:^1.1.0": version: 1.1.3 resolution: "json-markup@npm:1.1.3" @@ -24910,14 +24732,7 @@ __metadata: languageName: node linkType: hard -"json-schema@npm:0.2.3": - version: 0.2.3 - resolution: "json-schema@npm:0.2.3" - checksum: bbc2070988fb5f2a2266a31b956f1b5660e03ea7eaa95b33402901274f625feb586ae0c485e1df854fde40a7f0dc679f3b3ca8e5b8d31f8ea07a0d834de785c7 - languageName: node - linkType: hard - -"json-schema@npm:0.4.0": +"json-schema@npm:0.2.3, json-schema@npm:0.4.0": version: 0.4.0 resolution: "json-schema@npm:0.4.0" checksum: 66389434c3469e698da0df2e7ac5a3281bcff75e797a5c127db7c5b56270e01ae13d9afa3c03344f76e32e81678337a8c912bdbb75101c62e487dc3778461d72 @@ -25169,15 +24984,6 @@ __metadata: languageName: node linkType: hard -"keyv@npm:^3.0.0": - version: 3.1.0 - resolution: "keyv@npm:3.1.0" - dependencies: - json-buffer: 3.0.0 - checksum: bb7e8f3acffdbafbc2dd5b63f377fe6ec4c0e2c44fc82720449ef8ab54f4a7ce3802671ed94c0f475ae0a8549703353a2124561fcf3317010c141b32ca1ce903 - languageName: node - linkType: hard - "kind-of@npm:^3.0.2, kind-of@npm:^3.0.3, kind-of@npm:^3.2.0": version: 3.2.2 resolution: "kind-of@npm:3.2.2" @@ -25250,15 +25056,6 @@ __metadata: languageName: node linkType: hard -"latest-version@npm:^5.1.0": - version: 5.1.0 - resolution: "latest-version@npm:5.1.0" - dependencies: - package-json: ^6.3.0 - checksum: fbc72b071eb66c40f652441fd783a9cca62f08bf42433651937f078cd9ef94bf728ec7743992777826e4e89305aef24f234b515e6030503a2cbee7fc9bdc2c0f - languageName: node - linkType: hard - "lazy-ass@npm:^1.6.0": version: 1.6.0 resolution: "lazy-ass@npm:1.6.0" @@ -25815,20 +25612,6 @@ __metadata: languageName: node linkType: hard -"lowercase-keys@npm:^1.0.0, lowercase-keys@npm:^1.0.1": - version: 1.0.1 - resolution: "lowercase-keys@npm:1.0.1" - checksum: 4d045026595936e09953e3867722e309415ff2c80d7701d067546d75ef698dac218a4f53c6d1d0e7368b47e45fd7529df47e6cb56fbb90523ba599f898b3d147 - languageName: node - linkType: hard - -"lowercase-keys@npm:^2.0.0": - version: 2.0.0 - resolution: "lowercase-keys@npm:2.0.0" - checksum: 24d7ebd56ccdf15ff529ca9e08863f3c54b0b9d1edb97a3ae1af34940ae666c01a1e6d200707bce730a8ef76cb57cc10e65f245ecaaf7e6bc8639f2fb460ac23 - languageName: node - linkType: hard - "lowlight@npm:^1.14.0": version: 1.20.0 resolution: "lowlight@npm:1.20.0" @@ -25865,9 +25648,9 @@ __metadata: linkType: hard "lru-cache@npm:^7.4.4, lru-cache@npm:^7.5.1, lru-cache@npm:^7.7.1": - version: 7.10.2 - resolution: "lru-cache@npm:7.10.2" - checksum: b8f98064c981ce98e2a3b82903726dd28d842f54b7f53e1d6029c6836546243d4157b8495a405c062209715c612050150bdf30b5fa8200d5a3559fb0fd4072c1 + version: 7.12.0 + resolution: "lru-cache@npm:7.12.0" + checksum: fdb62262978393df7a4bd46a072bc5c3808c50ca5a347a82bb9459410efd841b7bae50655c3cf9004c70d12c756cf6d018f6bff155a16cdde9eba9a82899b5eb languageName: node linkType: hard @@ -26485,13 +26268,6 @@ __metadata: languageName: node linkType: hard -"mimic-response@npm:^1.0.0, mimic-response@npm:^1.0.1": - version: 1.0.1 - resolution: "mimic-response@npm:1.0.1" - checksum: 034c78753b0e622bc03c983663b1cdf66d03861050e0c8606563d149bc2b02d63f62ce4d32be4ab50d0553ae0ffe647fc34d1f5281184c6e1e8cf4d85e8d9823 - languageName: node - linkType: hard - "min-document@npm:^2.19.0": version: 2.19.0 resolution: "min-document@npm:2.19.0" @@ -26677,11 +26453,11 @@ __metadata: linkType: hard "minipass@npm:^3.1.6": - version: 3.3.3 - resolution: "minipass@npm:3.3.3" + version: 3.3.4 + resolution: "minipass@npm:3.3.4" dependencies: yallist: ^4.0.0 - checksum: 523a338f42140c2e62bff3429f236cc44a32ddd29a70d5221e0570ace237057190981cad406fd3a420f03a95cc001ad58a388d902b9519038e27f190bb88a6e7 + checksum: 5d95a7738c54852ba78d484141e850c792e062666a2d0c681a5ac1021275beb7e1acb077e59f9523ff1defb80901aea4e30fac10ded9a20a25d819a42916ef1b languageName: node linkType: hard @@ -26734,7 +26510,7 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.3, mkdirp@npm:^0.5.5": +"mkdirp@npm:^0.5.1, mkdirp@npm:^0.5.3": version: 0.5.5 resolution: "mkdirp@npm:0.5.5" dependencies: @@ -26745,6 +26521,17 @@ __metadata: languageName: node linkType: hard +"mkdirp@npm:^0.5.5": + version: 0.5.6 + resolution: "mkdirp@npm:0.5.6" + dependencies: + minimist: ^1.2.6 + bin: + mkdirp: bin/cmd.js + checksum: 0c91b721bb12c3f9af4b77ebf73604baf350e64d80df91754dc509491ae93bf238581e59c7188360cec7cb62fc4100959245a42cfe01834efedc5e9d068376c2 + languageName: node + linkType: hard + "mkdirp@npm:^1.0.3, mkdirp@npm:^1.0.4": version: 1.0.4 resolution: "mkdirp@npm:1.0.4" @@ -27106,10 +26893,10 @@ __metadata: languageName: node linkType: hard -"next-tick@npm:~1.0.0": - version: 1.0.0 - resolution: "next-tick@npm:1.0.0" - checksum: 83fcb3d4f8d9380210b1c2b8a610463602d80283f0c0c8571c1688e1ad6cbf3a16b345f5bb7212617d4898bedcfa10dff327dc09ec20a112a5bf43a0271375fb +"next-tick@npm:^1.1.0": + version: 1.1.0 + resolution: "next-tick@npm:1.1.0" + checksum: 83b5cf36027a53ee6d8b7f9c0782f2ba87f4858d977342bfc3c20c21629290a2111f8374d13a81221179603ffc4364f38374b5655d17b6a8f8a8c77bdea4fe8b languageName: node linkType: hard @@ -27332,26 +27119,6 @@ __metadata: languageName: node linkType: hard -"nodemon@npm:2.0.16": - version: 2.0.16 - resolution: "nodemon@npm:2.0.16" - dependencies: - chokidar: ^3.5.2 - debug: ^3.2.7 - ignore-by-default: ^1.0.1 - minimatch: ^3.0.4 - pstree.remy: ^1.1.8 - semver: ^5.7.1 - supports-color: ^5.5.0 - touch: ^3.1.0 - undefsafe: ^2.0.5 - update-notifier: ^5.1.0 - bin: - nodemon: bin/nodemon.js - checksum: ff818aa91b283bd0f1f6cfb8fdc9b5c3e74d5efa0cb72276dc242b742dc35d3f0720d35d99dbe85ecf6df7185f083ef9b1f7908094e43fd4e2508b6805d644dc - languageName: node - linkType: hard - "nopt@npm:^5.0.0": version: 5.0.0 resolution: "nopt@npm:5.0.0" @@ -27363,17 +27130,6 @@ __metadata: languageName: node linkType: hard -"nopt@npm:~1.0.10": - version: 1.0.10 - resolution: "nopt@npm:1.0.10" - dependencies: - abbrev: 1 - bin: - nopt: ./bin/nopt.js - checksum: f62575aceaa3be43f365bf37a596b89bbac2e796b001b6d2e2a85c2140a4e378ff919e2753ccba959c4fd344776fc88c29b393bc167fa939fb1513f126f4cd45 - languageName: node - linkType: hard - "normalize-package-data@npm:^2.3.2, normalize-package-data@npm:^2.5.0": version: 2.5.0 resolution: "normalize-package-data@npm:2.5.0" @@ -27433,13 +27189,6 @@ __metadata: languageName: node linkType: hard -"normalize-url@npm:^4.1.0": - version: 4.5.1 - resolution: "normalize-url@npm:4.5.1" - checksum: 9a9dee01df02ad23e171171893e56e22d752f7cff86fb96aafeae074819b572ea655b60f8302e2d85dbb834dc885c972cc1c573892fea24df46b2765065dd05a - languageName: node - linkType: hard - "normalize-url@npm:^6.0.1, normalize-url@npm:^6.1.0": version: 6.1.0 resolution: "normalize-url@npm:6.1.0" @@ -27510,8 +27259,8 @@ __metadata: linkType: hard "npm-packlist@npm:^5.1.0": - version: 5.1.0 - resolution: "npm-packlist@npm:5.1.0" + version: 5.1.1 + resolution: "npm-packlist@npm:5.1.1" dependencies: glob: ^8.0.1 ignore-walk: ^5.0.1 @@ -27519,7 +27268,7 @@ __metadata: npm-normalize-package-bin: ^1.0.1 bin: npm-packlist: bin/index.js - checksum: aeeed530858bd760236e49f42f36174a437e632406d71ee8af91f15ad42e3a49332365c3dfa3dc0686599506e3a3701d8c7eab157480993ad8634dbc5af27adc + checksum: 28dab153744ceb4695b82a9032d14aa2bfb855d38344a09052673d07860a4d8725f808ed23996e6f2792c48e11f5d147632c159f798d2c24dac92b51a884f0c6 languageName: node linkType: hard @@ -28012,13 +27761,6 @@ __metadata: languageName: node linkType: hard -"p-cancelable@npm:^1.0.0": - version: 1.1.0 - resolution: "p-cancelable@npm:1.1.0" - checksum: 2db3814fef6d9025787f30afaee4496a8857a28be3c5706432cbad76c688a6db1874308f48e364a42f5317f5e41e8e7b4f2ff5c8ff2256dbb6264bc361704ece - languageName: node - linkType: hard - "p-defer@npm:^1.0.0": version: 1.0.0 resolution: "p-defer@npm:1.0.0" @@ -28212,18 +27954,6 @@ __metadata: languageName: node linkType: hard -"package-json@npm:^6.3.0": - version: 6.5.0 - resolution: "package-json@npm:6.5.0" - dependencies: - got: ^9.6.0 - registry-auth-token: ^4.0.0 - registry-url: ^5.0.0 - semver: ^6.2.0 - checksum: cc9f890d3667d7610e6184decf543278b87f657d1ace0deb4a9c9155feca738ef88f660c82200763d3348010f4e42e9c7adc91e96ab0f86a770955995b5351e2 - languageName: node - linkType: hard - "pacote@npm:^13.0.3, pacote@npm:^13.0.5, pacote@npm:^13.4.1": version: 13.6.1 resolution: "pacote@npm:13.6.1" @@ -28394,27 +28124,27 @@ __metadata: languageName: node linkType: hard -"parse-path@npm:^4.0.0": - version: 4.0.3 - resolution: "parse-path@npm:4.0.3" +"parse-path@npm:^4.0.4": + version: 4.0.4 + resolution: "parse-path@npm:4.0.4" dependencies: is-ssh: ^1.3.0 protocols: ^1.4.0 qs: ^6.9.4 query-string: ^6.13.8 - checksum: d1704c0027489b64838c608c3f075fe3599c18a7413fa92e7074a0157e5bcc1a4ef73e7ae9bd9dbf5fad1809137437310cc69a57e5f5130ea17226165f3e942a + checksum: 909e628c35baebeb3bdcaa376e2c5a21632a9094079ac55e04b3311db28219b15e517e10987dd49a13a904f2605b747b6368b0092130e0f2ff9bc5ffc40ceb63 languageName: node linkType: hard "parse-url@npm:^6.0.0": - version: 6.0.0 - resolution: "parse-url@npm:6.0.0" + version: 6.0.2 + resolution: "parse-url@npm:6.0.2" dependencies: is-ssh: ^1.3.0 normalize-url: ^6.1.0 - parse-path: ^4.0.0 + parse-path: ^4.0.4 protocols: ^1.4.0 - checksum: 6b680d1fdfba15fc54106c1130540bf61a415bc3085351b8609a213b2fdf551c53ec8d32703d8ea9b6c5fbf2da92ee1593c99f682032512b15ce87f9013d2a39 + checksum: cbd11ad5e5100821aaee8ef2d05df339209e8bc87b2bdccc213bf27b85c0b50c5aee6ab96ad7401f18e0982ba913107c940bab30a8982db08337a056e667d917 languageName: node linkType: hard @@ -29924,13 +29654,6 @@ __metadata: languageName: node linkType: hard -"prepend-http@npm:^2.0.0": - version: 2.0.0 - resolution: "prepend-http@npm:2.0.0" - checksum: 7694a9525405447662c1ffd352fcb41b6410c705b739b6f4e3a3e21cf5fdede8377890088e8934436b8b17ba55365a615f153960f30877bf0d0392f9e93503ea - languageName: node - linkType: hard - "prettier@npm:2.6.0": version: 2.6.0 resolution: "prettier@npm:2.6.0" @@ -30236,13 +29959,20 @@ __metadata: languageName: node linkType: hard -"protocols@npm:^1.1.0, protocols@npm:^1.4.0": +"protocols@npm:^1.4.0": version: 1.4.8 resolution: "protocols@npm:1.4.8" checksum: 2d555c013df0b05402970f67f7207c9955a92b1d13ffa503c814b5fe2f6dde7ac6a03320e0975c1f5832b0113327865e0b3b28bfcad023c25ddb54b53fab8684 languageName: node linkType: hard +"protocols@npm:^2.0.1": + version: 2.0.1 + resolution: "protocols@npm:2.0.1" + checksum: 4a9bef6aa0449a0245ded319ac3cbfd032c3e76ebb562777037a3a832c99253d0e8bc2847f7be350236df620a11f7d4fe683ea7f59a2cc14c69f746b6259eda4 + languageName: node + linkType: hard + "proxy-addr@npm:~2.0.5, proxy-addr@npm:~2.0.7": version: 2.0.7 resolution: "proxy-addr@npm:2.0.7" @@ -30283,13 +30013,6 @@ __metadata: languageName: node linkType: hard -"pstree.remy@npm:^1.1.8": - version: 1.1.8 - resolution: "pstree.remy@npm:1.1.8" - checksum: 5cb53698d6bb34dfb278c8a26957964aecfff3e161af5fbf7cee00bbe9d8547c7aced4bd9cb193bce15fb56e9e4220fc02a5bf9c14345ffb13a36b858701ec2d - languageName: node - linkType: hard - "public-encrypt@npm:^4.0.0": version: 4.0.3 resolution: "public-encrypt@npm:4.0.3" @@ -30356,15 +30079,6 @@ __metadata: languageName: node linkType: hard -"pupa@npm:^2.1.1": - version: 2.1.1 - resolution: "pupa@npm:2.1.1" - dependencies: - escape-goat: ^2.0.0 - checksum: 49529e50372ffdb0cccf0efa0f3b3cb0a2c77805d0d9cc2725bd2a0f6bb414631e61c93a38561b26be1259550b7bb6c2cb92315aa09c8bf93f3bdcb49f2b2fb7 - languageName: node - linkType: hard - "q@npm:^1.5.1": version: 1.5.1 resolution: "q@npm:1.5.1" @@ -30395,7 +30109,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.4.0, qs@npm:^6.9.4": +"qs@npm:^6.4.0": version: 6.10.1 resolution: "qs@npm:6.10.1" dependencies: @@ -30404,6 +30118,15 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.9.4": + version: 6.11.0 + resolution: "qs@npm:6.11.0" + dependencies: + side-channel: ^1.0.4 + checksum: 6e1f29dd5385f7488ec74ac7b6c92f4d09a90408882d0c208414a34dd33badc1a621019d4c799a3df15ab9b1d0292f97c1dd71dc7c045e69f81a8064e5af7297 + languageName: node + linkType: hard + "qs@npm:~6.5.2": version: 6.5.2 resolution: "qs@npm:6.5.2" @@ -30879,20 +30602,6 @@ __metadata: languageName: node linkType: hard -"rc@npm:^1.2.8": - version: 1.2.8 - resolution: "rc@npm:1.2.8" - dependencies: - deep-extend: ^0.6.0 - ini: ~1.3.0 - minimist: ^1.2.0 - strip-json-comments: ~2.0.1 - bin: - rc: ./cli.js - checksum: 2e26e052f8be2abd64e6d1dabfbd7be03f80ec18ccbc49562d31f617d0015fbdbcf0f9eed30346ea6ab789e0fdfe4337f033f8016efdbee0df5354751842080e - languageName: node - linkType: hard - "re-resizable@npm:6.9.9": version: 6.9.9 resolution: "re-resizable@npm:6.9.9" @@ -31970,14 +31679,14 @@ __metadata: linkType: hard "read-package-json@npm:^4.1.1": - version: 4.1.1 - resolution: "read-package-json@npm:4.1.1" + version: 4.1.2 + resolution: "read-package-json@npm:4.1.2" dependencies: glob: ^7.1.1 json-parse-even-better-errors: ^2.3.0 normalize-package-data: ^3.0.0 npm-normalize-package-bin: ^1.0.0 - checksum: d95f6e9747bcce9bdbfae8442a86c41cde3a73691a8a8cdc46e0711e7768718e1f0955a38cbde01a6e571f490bbdc9d6a83713a89eca85646a816e659a78f6f4 + checksum: 729acda12fdbff6cee8cee7b6023a16e85c02406e2427b3cd091948d945940cfb6a6ebe7a8b4df967d483f360d0ec12fb83ab80de3e7bbb2ba2c426d07fd774e languageName: node linkType: hard @@ -32328,24 +32037,6 @@ __metadata: languageName: node linkType: hard -"registry-auth-token@npm:^4.0.0": - version: 4.2.1 - resolution: "registry-auth-token@npm:4.2.1" - dependencies: - rc: ^1.2.8 - checksum: aa72060b573a50607cfd2dee16d0e51e13ca58b6a80442e74545325dc24d2c38896e6bad229bdcc1fc9759fa81b4066be8693d4d6f45927318e7c793a93e9cd0 - languageName: node - linkType: hard - -"registry-url@npm:^5.0.0": - version: 5.1.0 - resolution: "registry-url@npm:5.1.0" - dependencies: - rc: ^1.2.8 - checksum: bcea86c84a0dbb66467b53187fadebfea79017cddfb4a45cf27530d7275e49082fe9f44301976eb0164c438e395684bcf3dae4819b36ff9d1640d8cc60c73df9 - languageName: node - linkType: hard - "regjsgen@npm:^0.5.2": version: 0.5.2 resolution: "regjsgen@npm:0.5.2" @@ -32768,15 +32459,6 @@ __metadata: languageName: node linkType: hard -"responselike@npm:^1.0.2": - version: 1.0.2 - resolution: "responselike@npm:1.0.2" - dependencies: - lowercase-keys: ^1.0.0 - checksum: 2e9e70f1dcca3da621a80ce71f2f9a9cad12c047145c6ece20df22f0743f051cf7c73505e109814915f23f9e34fb0d358e22827723ee3d56b623533cab8eafcd - languageName: node - linkType: hard - "restore-cursor@npm:^3.1.0": version: 3.1.0 resolution: "restore-cursor@npm:3.1.0" @@ -33315,16 +32997,7 @@ __metadata: languageName: node linkType: hard -"semver-diff@npm:^3.1.1": - version: 3.1.1 - resolution: "semver-diff@npm:3.1.1" - dependencies: - semver: ^6.3.0 - checksum: 8bbe5a5d7add2d5e51b72314a9215cd294d71f41cdc2bf6bd59ee76411f3610b576172896f1d191d0d7294cb9f2f847438d2ee158adacc0c224dca79052812fe - languageName: node - linkType: hard - -"semver@npm:2 || 3 || 4 || 5, semver@npm:^5.3.0, semver@npm:^5.4.1, semver@npm:^5.5.0, semver@npm:^5.6.0, semver@npm:^5.7.1": +"semver@npm:2 || 3 || 4 || 5, semver@npm:^5.3.0, semver@npm:^5.4.1, semver@npm:^5.5.0, semver@npm:^5.6.0": version: 5.7.1 resolution: "semver@npm:5.7.1" bin: @@ -33342,7 +33015,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.3.7, semver@npm:^7.0.0, semver@npm:^7.3.7": +"semver@npm:7.3.7, semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.1.3, semver@npm:^7.3.7": version: 7.3.7 resolution: "semver@npm:7.3.7" dependencies: @@ -33353,7 +33026,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:7.x, semver@npm:^7.1.1, semver@npm:^7.1.3, semver@npm:^7.3.2, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:~7.3.0": +"semver@npm:7.x, semver@npm:^7.3.2, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:~7.3.0": version: 7.3.5 resolution: "semver@npm:7.3.5" dependencies: @@ -33364,7 +33037,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^6.0.0, semver@npm:^6.1.1, semver@npm:^6.1.2, semver@npm:^6.2.0, semver@npm:^6.3.0": +"semver@npm:^6.0.0, semver@npm:^6.1.1, semver@npm:^6.1.2, semver@npm:^6.3.0": version: 6.3.0 resolution: "semver@npm:6.3.0" bin: @@ -33919,17 +33592,7 @@ __metadata: languageName: node linkType: hard -"socks@npm:^2.3.3, socks@npm:^2.6.1": - version: 2.6.1 - resolution: "socks@npm:2.6.1" - dependencies: - ip: ^1.1.5 - smart-buffer: ^4.1.0 - checksum: 2ca9d616e424f645838ebaabb04f85d94ea999e0f8393dc07f86c435af22ed88cb83958feeabd1bb7bc537c635ed47454255635502c6808a6df61af1f41af750 - languageName: node - linkType: hard - -"socks@npm:^2.6.2": +"socks@npm:^2.3.3, socks@npm:^2.6.2": version: 2.6.2 resolution: "socks@npm:2.6.2" dependencies: @@ -33939,6 +33602,16 @@ __metadata: languageName: node linkType: hard +"socks@npm:^2.6.1": + version: 2.6.1 + resolution: "socks@npm:2.6.1" + dependencies: + ip: ^1.1.5 + smart-buffer: ^4.1.0 + checksum: 2ca9d616e424f645838ebaabb04f85d94ea999e0f8393dc07f86c435af22ed88cb83958feeabd1bb7bc537c635ed47454255635502c6808a6df61af1f41af750 + languageName: node + linkType: hard + "sort-asc@npm:^0.1.0": version: 0.1.0 resolution: "sort-asc@npm:0.1.0" @@ -34716,13 +34389,6 @@ __metadata: languageName: node linkType: hard -"strip-json-comments@npm:~2.0.1": - version: 2.0.1 - resolution: "strip-json-comments@npm:2.0.1" - checksum: 1074ccb63270d32ca28edfb0a281c96b94dc679077828135141f27d52a5a398ef5e78bcf22809d23cadc2b81dfbe345eb5fd8699b385c8b1128907dec4a7d1e1 - languageName: node - linkType: hard - "strong-log-transformer@npm:^2.1.0": version: 2.1.0 resolution: "strong-log-transformer@npm:2.1.0" @@ -34923,7 +34589,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^5.3.0, supports-color@npm:^5.5.0": +"supports-color@npm:^5.3.0": version: 5.5.0 resolution: "supports-color@npm:5.5.0" dependencies: @@ -35474,13 +35140,6 @@ __metadata: languageName: node linkType: hard -"to-readable-stream@npm:^1.0.0": - version: 1.0.0 - resolution: "to-readable-stream@npm:1.0.0" - checksum: 2bd7778490b6214a2c40276065dd88949f4cf7037ce3964c76838b8cb212893aeb9cceaaf4352a4c486e3336214c350270f3263e1ce7a0c38863a715a4d9aeb5 - languageName: node - linkType: hard - "to-regex-range@npm:^2.1.0": version: 2.1.1 resolution: "to-regex-range@npm:2.1.1" @@ -35549,17 +35208,6 @@ __metadata: languageName: node linkType: hard -"touch@npm:^3.1.0": - version: 3.1.0 - resolution: "touch@npm:3.1.0" - dependencies: - nopt: ~1.0.10 - bin: - nodetouch: ./bin/nodetouch.js - checksum: e0be589cb5b0e6dbfce6e7e077d4a0d5f0aba558ef769c6d9c33f635e00d73d5be49da6f8631db302ee073919d82b5b7f56da2987feb28765c95a7673af68647 - languageName: node - linkType: hard - "tough-cookie@npm:^4.0.0": version: 4.0.0 resolution: "tough-cookie@npm:4.0.0" @@ -35642,9 +35290,9 @@ __metadata: linkType: hard "trim@npm:0.0.1": - version: 0.0.1 - resolution: "trim@npm:0.0.1" - checksum: 2b4646dff99a222e8e1526edd4e3a43bbd925af0b8e837c340455d250157e7deefaa4da49bb891ab841e5c27b1afc5e9e32d4b57afb875d2dfcabf4e319b8f7f + version: 0.0.3 + resolution: "trim@npm:0.0.3" + checksum: 9a059ba56d5e22c9e571798a7c63640cb25478c495d8a9d001f6352927207c6bd224018751a0c5145fbedc943ee2ebab1d7cc2e8ccba3121a51a7d3428dd879c languageName: node linkType: hard @@ -36046,9 +35694,9 @@ __metadata: linkType: hard "type@npm:^2.5.0": - version: 2.5.0 - resolution: "type@npm:2.5.0" - checksum: 0fe1bb4e8ba298b2b245fdc6bca6178887e29e2134d231e468366615b3adffd651d464eb51d8b15f8cfd168577c282a17e19bf80f036a60d4df16308a83a93c4 + version: 2.6.0 + resolution: "type@npm:2.6.0" + checksum: 80da01fcc0f6ed5a253dc326530e134000a8f66ea44b6d9687cde2f894f0d0b2486595b0cd040a64f7f79dc3120784236f8c9ef667a8aef03984e049b447cfb4 languageName: node linkType: hard @@ -36156,13 +35804,6 @@ __metadata: languageName: node linkType: hard -"undefsafe@npm:^2.0.5": - version: 2.0.5 - resolution: "undefsafe@npm:2.0.5" - checksum: f42ab3b5770fedd4ada175fc1b2eb775b78f609156f7c389106aafd231bfc210813ee49f54483d7191d7b76e483bc7f537b5d92d19ded27156baf57592eb02cc - languageName: node - linkType: hard - "underscore@npm:1.13.3": version: 1.13.3 resolution: "underscore@npm:1.13.3" @@ -36271,15 +35912,6 @@ __metadata: languageName: node linkType: hard -"unique-string@npm:^2.0.0": - version: 2.0.0 - resolution: "unique-string@npm:2.0.0" - dependencies: - crypto-random-string: ^2.0.0 - checksum: ef68f639136bcfe040cf7e3cd7a8dff076a665288122855148a6f7134092e6ed33bf83a7f3a9185e46c98dddc445a0da6ac25612afa1a7c38b8b654d6c02498e - languageName: node - linkType: hard - "unist-builder@npm:2.0.3, unist-builder@npm:^2.0.0": version: 2.0.3 resolution: "unist-builder@npm:2.0.3" @@ -36435,28 +36067,6 @@ __metadata: languageName: node linkType: hard -"update-notifier@npm:^5.1.0": - version: 5.1.0 - resolution: "update-notifier@npm:5.1.0" - dependencies: - boxen: ^5.0.0 - chalk: ^4.1.0 - configstore: ^5.0.1 - has-yarn: ^2.1.0 - import-lazy: ^2.1.0 - is-ci: ^2.0.0 - is-installed-globally: ^0.4.0 - is-npm: ^5.0.0 - is-yarn-global: ^0.3.0 - latest-version: ^5.1.0 - pupa: ^2.1.1 - semver: ^7.3.4 - semver-diff: ^3.1.1 - xdg-basedir: ^4.0.0 - checksum: 461e5e5b002419296d3868ee2abe0f9ab3e1846d9db642936d0c46f838872ec56069eddfe662c45ce1af0a8d6d5026353728de2e0a95ab2e3546a22ea077caf1 - languageName: node - linkType: hard - "uplot@npm:1.6.21": version: 1.6.21 resolution: "uplot@npm:1.6.21" @@ -36504,15 +36114,6 @@ __metadata: languageName: node linkType: hard -"url-parse-lax@npm:^3.0.0": - version: 3.0.0 - resolution: "url-parse-lax@npm:3.0.0" - dependencies: - prepend-http: ^2.0.0 - checksum: 1040e357750451173132228036aff1fd04abbd43eac1fb3e4fca7495a078bcb8d33cb765fe71ad7e473d9c94d98fd67adca63bd2716c815a2da066198dd37217 - languageName: node - linkType: hard - "url@npm:^0.11.0": version: 0.11.0 resolution: "url@npm:0.11.0" @@ -37754,13 +37355,6 @@ __metadata: languageName: node linkType: hard -"xdg-basedir@npm:^4.0.0": - version: 4.0.0 - resolution: "xdg-basedir@npm:4.0.0" - checksum: 0073d5b59a37224ed3a5ac0dd2ec1d36f09c49f0afd769008a6e9cd3cd666bd6317bd1c7ce2eab47e1de285a286bad11a9b038196413cd753b79770361855f3c - languageName: node - linkType: hard - "xml-name-validator@npm:^3.0.0": version: 3.0.0 resolution: "xml-name-validator@npm:3.0.0"