diff --git a/packages/grafana-ui/src/components/Layout/Layout.tsx b/packages/grafana-ui/src/components/Layout/Layout.tsx index 3a96c47440b..2e53b8a3c0d 100644 --- a/packages/grafana-ui/src/components/Layout/Layout.tsx +++ b/packages/grafana-ui/src/components/Layout/Layout.tsx @@ -36,12 +36,13 @@ export const Layout: React.FC = ({ align = 'normal', wrap = false, width = '100%', + height = '100%', ...rest }) => { const theme = useTheme(); const styles = getStyles(theme, orientation, spacing, justify, align, wrap); return ( -
+
{React.Children.toArray(children) .filter(Boolean) .map((child, index) => { @@ -62,6 +63,7 @@ export const HorizontalGroup: React.FC> = ({ align = 'center', wrap, width, + height, }) => ( > = ({ orientation={Orientation.Horizontal} align={align} width={width} + height={height} wrap={wrap} > {children} @@ -80,8 +83,16 @@ export const VerticalGroup: React.FC> justify, align, width, + height, }) => ( - + {children} ); diff --git a/pkg/plugins/manager/manager_test.go b/pkg/plugins/manager/manager_test.go index 030334bfdc8..cbba5695004 100644 --- a/pkg/plugins/manager/manager_test.go +++ b/pkg/plugins/manager/manager_test.go @@ -38,14 +38,7 @@ func TestPluginManager_Init(t *testing.T) { assert.Empty(t, pm.scanningErrors) verifyCorePluginCatalogue(t, pm) - - // verify bundled plugins - assert.NotNil(t, pm.plugins["input"]) - assert.NotNil(t, pm.dataSources["input"]) - - assert.Len(t, pm.StaticRoutes(), 1) - assert.Equal(t, "input", pm.StaticRoutes()[0].PluginId) - assert.True(t, strings.HasPrefix(pm.StaticRoutes()[0].Directory, bundledPluginsPath+"/input-datasource/")) + verifyBundledPluginCatalogue(t, pm) }) t.Run("Base case with single external plugin", func(t *testing.T) { @@ -478,6 +471,27 @@ func verifyCorePluginCatalogue(t *testing.T, pm *PluginManager) { } } +func verifyBundledPluginCatalogue(t *testing.T, pm *PluginManager) { + t.Helper() + + bundledPlugins := map[string]string{ + "input": "input-datasource", + "grafana-marketplace-app": "marketplace-app", + } + + for pluginID, pluginDir := range bundledPlugins { + assert.NotNil(t, pm.plugins[pluginID]) + for _, route := range pm.staticRoutes { + if pluginID == route.PluginId { + assert.True(t, strings.HasPrefix(route.Directory, pm.Cfg.BundledPluginsPath+"/"+pluginDir)) + } + } + } + + assert.NotNil(t, pm.dataSources["input"]) + assert.NotNil(t, pm.apps["grafana-marketplace-app"]) +} + type fakeBackendPluginManager struct { backendplugin.Manager diff --git a/plugins-bundled/internal/marketplace-app/LICENSE b/plugins-bundled/internal/marketplace-app/LICENSE new file mode 100644 index 00000000000..8dada3edaf5 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins-bundled/internal/marketplace-app/README.md b/plugins-bundled/internal/marketplace-app/README.md new file mode 100644 index 00000000000..5d4c058be40 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/README.md @@ -0,0 +1,21 @@ +# Marketplace for Grafana + +Browse and manage plugins from within Grafana. + +- Admin users can browse plugins, list installed plugins, and install / uninstall plugins + +![Screenshot](/src/img/discover.png) + +## Installation + +1. Navigate to **Configuration** -> **Plugins** and click on the Marketplace plugin in the list +1. Click the **Enable app** to enable the plugin +1. Click the **Pin app** to add it to the side menu + +## Configuration + +| Option | Description | +| ------------------------- | ------------------------------------------------ | +| _Enable app_ | Must be done before being able to use the plugin | +| _Pin app_ | Add the app to the side menu | +| _Show Enterprise plugins_ | Show Enterprise plugins in the marketplace | diff --git a/plugins-bundled/internal/marketplace-app/jest.config.js b/plugins-bundled/internal/marketplace-app/jest.config.js new file mode 100644 index 00000000000..bcf17c9087d --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/jest.config.js @@ -0,0 +1,8 @@ +// This file is needed because it is used by vscode and other tools that +// call `jest` directly. However, unless you are doing anything special +// do not edit this file + +const standard = require('@grafana/toolkit/src/config/jest.plugin.config'); + +// This process will use the same config that `yarn test` is using +module.exports = standard.jestConfig(); diff --git a/plugins-bundled/internal/marketplace-app/package.json b/plugins-bundled/internal/marketplace-app/package.json new file mode 100644 index 00000000000..f100596331f --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/package.json @@ -0,0 +1,29 @@ +{ + "name": "@grafana-plugins/marketplace-app", + "version": "7.5.0-pre.0", + "description": "Marketplace", + "private": true, + "repository": { + "type": "git", + "url": "http://github.com/grafana/grafana.git" + }, + "scripts": { + "build": "grafana-toolkit plugin:build", + "test": "grafana-toolkit plugin:test", + "dev": "grafana-toolkit plugin:dev", + "watch": "grafana-toolkit plugin:dev --watch" + }, + "author": "Grafana Labs", + "license": "Apache-2.0", + "devDependencies": { + "@grafana/data": "7.5.0-pre.0", + "@grafana/runtime": "7.5.0-pre.0", + "@grafana/toolkit": "7.5.0-pre.0", + "@grafana/ui": "7.5.0-pre.0", + "@types/semver": "^7.3.4", + "semver": "^7.3.4" + }, + "volta": { + "node": "12.16.2" + } +} diff --git a/plugins-bundled/internal/marketplace-app/src/RootPage.tsx b/plugins-bundled/internal/marketplace-app/src/RootPage.tsx new file mode 100644 index 00000000000..c54561ad9d0 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/RootPage.tsx @@ -0,0 +1,15 @@ +import { AppRootProps } from '@grafana/data'; +import { pages } from 'pages'; +import React from 'react'; + +export const MarketplaceRootPage = React.memo(function MarketplaceRootPage(props: AppRootProps) { + const { + path, + query: { tab }, + } = props; + // Required to support grafana instances that use a custom `root_url`. + const pathWithoutLeadingSlash = path.replace(/^\//, ''); + + const Page = pages.find(({ id }) => id === tab)?.component || pages[0].component; + return ; +}); diff --git a/plugins-bundled/internal/marketplace-app/src/api.ts b/plugins-bundled/internal/marketplace-app/src/api.ts new file mode 100644 index 00000000000..48d8420397c --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/api.ts @@ -0,0 +1,71 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { PluginMeta } from '@grafana/data'; +import { API_ROOT, GRAFANA_API_ROOT } from './constants'; +import { Plugin, PluginDetails, Org } from './types'; + +async function getRemotePlugins(): Promise { + const res = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins`); + return res.items; +} + +async function getPlugin(slug: string): Promise { + const res = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${slug}`); + + const versions = await getPluginVersions(slug); + const installed = await getInstalledPlugins(); + + const plugin = installed?.find((_: any) => { + return _.id === slug; + }); + + return { + remote: res, + remoteVersions: versions, + local: plugin, + }; +} + +async function getPluginVersions(id: string): Promise { + const versions = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${id}/versions`); + return versions.items; +} + +async function getInstalledPlugins(): Promise { + const installed = await getBackendSrv().get(`${API_ROOT}?core=0`); + return installed; +} + +async function getOrg(slug: string): Promise { + const org = await getBackendSrv().get(`${GRAFANA_API_ROOT}/orgs/${slug}`); + return { ...org, avatarUrl: `${GRAFANA_API_ROOT}/orgs/${slug}/avatar` }; +} + +async function installPlugin(id: string, version: string) { + return await getBackendSrv().post(`${API_ROOT}/${id}/install`, { + version, + }); +} + +async function uninstallPlugin(id: string) { + return await getBackendSrv().post(`${API_ROOT}/${id}/uninstall`); +} + +async function updatePlugin(pluginId: string, data: Partial) { + const response = await getBackendSrv().datasourceRequest({ + url: `/api/plugins/${pluginId}/settings`, + method: 'POST', + data, + }); + + return response?.data; +} + +export const api = { + getRemotePlugins, + getPlugin, + getInstalledPlugins, + getOrg, + installPlugin, + uninstallPlugin, + updatePlugin, +}; diff --git a/plugins-bundled/internal/marketplace-app/src/components/Card.tsx b/plugins-bundled/internal/marketplace-app/src/components/Card.tsx new file mode 100644 index 00000000000..546ae2988c0 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/components/Card.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useTheme2, stylesFactory } from '@grafana/ui'; + +interface Props { + href: string; + text: React.ReactNode; + image: React.ReactNode; + layout?: 'horizontal' | 'vertical'; +} + +export const Card = ({ href, text, image, layout = 'vertical' }: Props) => { + const theme = useTheme2(); + const styles = getCardStyles(theme, layout); + + return ( + +
+
{image}
+ {text} +
+
+ ); +}; + +const getCardStyles = stylesFactory((theme: GrafanaTheme2, layout) => ({ + root: css` + background-color: ${theme.colors.background.primary}; + border-radius: ${theme.shape.borderRadius()}; + cursor: pointer; + height: 100%; + padding: ${theme.spacing(2)}; + + &:hover { + background-color: ${theme.colors.action.hover}; + } + `, + container: css` + display: flex; + flex-direction: ${layout === 'vertical' ? 'column' : 'row'}; + justify-content: ${layout === 'vertical' ? 'space-around' : 'flex-start'}; + height: 100%; + `, + imgContainer: css` + flex-grow: ${layout === 'vertical' ? 1 : 0}; + padding: ${theme.spacing()} 0; + display: flex; + justify-content: center; + align-items: center; + `, +})); diff --git a/plugins-bundled/internal/marketplace-app/src/components/Grid.tsx b/plugins-bundled/internal/marketplace-app/src/components/Grid.tsx new file mode 100644 index 00000000000..4b6fcdd2878 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/components/Grid.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { useTheme2 } from '@grafana/ui'; + +interface Props { + children: React.ReactNode; +} + +export const Grid = ({ children }: Props) => { + const theme = useTheme2(); + + return ( +
+ {children} +
+ ); +}; diff --git a/plugins-bundled/internal/marketplace-app/src/components/HorizontalGroup.tsx b/plugins-bundled/internal/marketplace-app/src/components/HorizontalGroup.tsx new file mode 100644 index 00000000000..e83a2e19ce6 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/components/HorizontalGroup.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { css } from '@emotion/css'; + +import { useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; + +interface HorizontalGroupProps { + children: React.ReactNode; +} + +export const HorizontalGroup = ({ children }: HorizontalGroupProps) => { + const styles = useStyles2(getStyles); + + return
{children}
; +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css` + display: flex; + align-items: center; + justify-content: space-between; + & > * { + margin-right: ${theme.spacing()}; + } + & > *:first-child { + flex-grow: 1; + } + & > *:last-child { + margin-right: 0; + } + `, +}); diff --git a/plugins-bundled/internal/marketplace-app/src/components/InstallControls.tsx b/plugins-bundled/internal/marketplace-app/src/components/InstallControls.tsx new file mode 100644 index 00000000000..a6f728d6d7d --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/components/InstallControls.tsx @@ -0,0 +1,156 @@ +import React, { useState } from 'react'; +import { css } from '@emotion/css'; +import { gt, satisfies } from 'semver'; + +import { config } from '@grafana/runtime'; +import { Button, HorizontalGroup, Icon, useStyles2 } from '@grafana/ui'; +import { AppEvents, GrafanaTheme2, OrgRole } from '@grafana/data'; + +import { Metadata, Plugin } from '../types'; +import { hasRole } from '../helpers'; +import { api } from '../api'; + +// This isn't exported in the sdk yet +// @ts-ignore +import appEvents from 'grafana/app/core/app_events'; + +interface Props { + localPlugin?: Metadata; + remotePlugin: Plugin; + slug: string; +} + +export const InstallControls = ({ localPlugin, remotePlugin, slug }: Props) => { + const [loading, setLoading] = useState(false); + const [isInstalled, setIsInstalled] = useState(Boolean(localPlugin)); + const [shouldUpdate, setShouldUpdate] = useState( + remotePlugin?.version && localPlugin?.info.version && gt(remotePlugin?.version!, localPlugin?.info.version!) + ); + + const styles = useStyles2(getStyles); + + const onInstall = async () => { + setLoading(true); + try { + await api.installPlugin(slug, remotePlugin.version); + appEvents.emit(AppEvents.alertSuccess, [`Installed ${remotePlugin?.name}`]); + setLoading(false); + setIsInstalled(true); + } catch (error) { + setLoading(false); + } + }; + + const onUninstall = async () => { + setLoading(true); + try { + await api.uninstallPlugin(slug); + appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${remotePlugin?.name}`]); + setLoading(false); + setIsInstalled(false); + } catch (error) { + setLoading(false); + } + }; + + const onUpdate = async () => { + setLoading(true); + try { + await api.installPlugin(slug, remotePlugin.version); + appEvents.emit(AppEvents.alertSuccess, [`Updated ${remotePlugin?.name}`]); + setLoading(false); + setShouldUpdate(false); + } catch (error) { + setLoading(false); + } + }; + + const grafanaDependency = remotePlugin?.json?.dependencies?.grafanaDependency; + const unsupportedGrafanaVersion = grafanaDependency + ? !satisfies(config.buildInfo.version, grafanaDependency, { + // needed for when running against master + includePrerelease: true, + }) + : false; + + const isDevelopmentBuild = Boolean(localPlugin?.dev); + const isEnterprise = remotePlugin?.status === 'enterprise'; + const hasPermission = hasRole(OrgRole.Admin); + + if (isEnterprise) { + return ( +
+ Marketplace doesn't support installing Enterprise plugins yet. Stay tuned! +
+ ); + } + + if (isDevelopmentBuild) { + return ( +
This is a development build of the plugin and can't be uninstalled.
+ ); + } + + if (isInstalled) { + return ( + + {shouldUpdate && ( + + )} + + {!hasPermission &&
You need admin privileges to manage this plugin.
} +
+ ); + } + + if (unsupportedGrafanaVersion) { + return ( +
+ +  This plugin doesn't support your version of Grafana. +
+ ); + } + + return ( + + + {!hasPermission &&
You need admin privileges to install this plugin.
} +
+ ); +}; + +export const getStyles = (theme: GrafanaTheme2) => { + return { + message: css` + color: ${theme.colors.text.secondary}; + `, + readme: css` + margin: ${theme.spacing(3)} 0; + + & img { + max-width: 100%; + } + + h1, + h2, + h3 { + margin-top: ${theme.spacing(3)}; + margin-bottom: ${theme.spacing(2)}; + } + + li { + margin-left: ${theme.spacing(2)}; + & > p { + margin: ${theme.spacing()} 0; + } + } + `, + }; +}; diff --git a/plugins-bundled/internal/marketplace-app/src/components/Loader.tsx b/plugins-bundled/internal/marketplace-app/src/components/Loader.tsx new file mode 100644 index 00000000000..8cfa0d05dff --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/components/Loader.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { LoadingPlaceholder } from '@grafana/ui'; +import { Page } from './Page'; + +export const Loader = () => { + return ( + +
+ +
+
+ ); +}; diff --git a/plugins-bundled/internal/marketplace-app/src/components/Page.tsx b/plugins-bundled/internal/marketplace-app/src/components/Page.tsx new file mode 100644 index 00000000000..fd3ac726632 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/components/Page.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; + +export const Page: React.FC = ({ children }) => { + const styles = useStyles2(getStyles); + return ( +
+
{children}
+
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => + css` + margin-bottom: ${theme.spacing(3)}; + `; diff --git a/plugins-bundled/internal/marketplace-app/src/components/PluginList.tsx b/plugins-bundled/internal/marketplace-app/src/components/PluginList.tsx new file mode 100644 index 00000000000..bd2f20fb9b0 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/components/PluginList.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { useStyles2 } from '@grafana/ui'; +import { css } from '@emotion/css'; +import { Card } from '../components/Card'; +import { Grid } from '../components/Grid'; + +import { PLUGIN_ROOT } from '../constants'; +import { Plugin } from '../types'; +import { GrafanaTheme2 } from '@grafana/data'; + +interface Props { + plugins: Plugin[]; +} + +export const PluginList = ({ plugins }: Props) => { + const styles = useStyles2(getStyles); + + return ( + + {plugins.map((plugin) => { + const { name, slug, version, orgName, typeCode } = plugin; + + return ( + + } + text={ + <> +
{name}
+
{orgName}
+ + } + /> + ); + })} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + name: css` + font-size: ${theme.typography.h4.fontSize}; + color: ${theme.colors.text}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + `, + orgName: css` + font-size: ${theme.typography.body.fontSize}; + color: ${theme.colors.text.secondary}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + `, +}); diff --git a/plugins-bundled/internal/marketplace-app/src/components/PluginTypeIcon.tsx b/plugins-bundled/internal/marketplace-app/src/components/PluginTypeIcon.tsx new file mode 100644 index 00000000000..f4453132daf --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/components/PluginTypeIcon.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { PluginTypeCode } from 'types'; + +interface PluginTypeIconProps { + typeCode: PluginTypeCode; + size: number; +} + +export const PluginTypeIcon = ({ typeCode, size }: PluginTypeIconProps) => { + const imageUrl = ((typeCode: string) => { + switch (typeCode) { + case 'panel': + return 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNSIgaGVpZ2h0PSIyNC4zMzEiIHZpZXdCb3g9Ii02MiA2My42NjkgMjUgMjQuMzMxIj48dGl0bGU+aWNvbl9kYXRhLXNvdXJjZTwvdGl0bGU+PHBhdGggZD0iTS00MS40MDUgNjMuNjczaC0xNi4xOUE0LjQxIDQuNDEgMCAwIDAtNjIgNjguMDc4djE1LjUxN0E0LjQxIDQuNDEgMCAwIDAtNTcuNTk1IDg4aDE2LjE5QTQuNDEgNC40MSAwIDAgMC0zNyA4My41OTVWNjguMDc4YTQuNDEgNC40MSAwIDAgMC00LjQwNS00LjQwNXptMy43MjcgMTkuOTIyYTMuNzMxIDMuNzMxIDAgMCAxLTMuNzI3IDMuNzI3aC0xNi4xOWEzLjczMSAzLjczMSAwIDAgMS0zLjcyNy0zLjcyN1Y2OC4wNzhhMy43MzEgMy43MzEgMCAwIDEgMy43MjctMy43MjdoMTYuMTlhMy43MzEgMy43MzEgMCAwIDEgMy43MjcgMy43Mjd2MTUuNTE3eiIgZmlsbD0iIzg5ODk4OSIvPjxnIGZpbGw9IiM4OTg5ODkiPjxwYXRoIGQ9Ik0tNTYuNDU3IDg1LjE0N2gxMy45MTRhMi4zNSAyLjM1IDAgMCAwIDIuMjctMS43NTloLTE4LjQ1NGEyLjM1MSAyLjM1MSAwIDAgMCAyLjI3IDEuNzU5em0uMDQ3LTguNzA2bDIuMDg3LjgzLjgxLS45NzdoLTUuMjk5djEuNjgzbDEuNjM2LTEuNDA4YS43NTEuNzUxIDAgMCAxIC43NjYtLjEyOHptNS44MzktMy42OTRoLTguMjQxdjIuODI4aDUuODk1em03Ljk0OSAyLjgyOGguNzM5bDEuNjk1LTEuMzA0di0xLjUyNGgtNC4yN3ptLTE2LjE5IDQuMzgxdjIuNzEzaDE4LjYyNHYtMi44MjhoLTE4LjQ5MXptOS43NjYtOS4wNDdhLjc0OC43NDggMCAwIDEgLjg5MS0uMjAybDIuODY5IDEuMzIyaDUuMDk5VjY5LjJoLTE4LjYyNXYyLjgyOGg4LjgzOGwuOTI4LTEuMTE5em02LjUwMy00LjM4N2gtMTMuOTE0YTIuMzUyIDIuMzUyIDAgMCAwLTIuMzE2IDEuOTZoMTguNTQ1YTIuMzUgMi4zNSAwIDAgMC0yLjMxNS0xLjk2em0tNC43NjggNi4yMjVoLTEuMzExbC0yLjM0NiAyLjgyOGg2LjU1OGwtMS4zODItMi4xMjh6Ii8+PHBhdGggZD0iTS00Mi4xMDUgNzcuNjM5YS43NDcuNzQ3IDAgMCAxLTEuMDg2LS4xODZsLS43NTItMS4xNThoLTcuNjIxbC0xLjk1MiAyLjM1NGEuNzUuNzUgMCAwIDEtLjg1NC4yMTlsLTIuMTcyLS44NjQtMS4zMDEgMS4xMmgxNy42NTd2LTIuODI4aC0uMTdsLTEuNzQ5IDEuMzQzeiIvPjwvZz48L3N2Zz4='; + case 'datasource': + return 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxOS4wLjEsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMjVweCIgaGVpZ2h0PSIyNC4zcHgiIHZpZXdCb3g9Ii0xODcgNzMuNyAyNSAyNC4zIiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IC0xODcgNzMuNyAyNSAyNC4zOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSINCgk+DQo8c3R5bGUgdHlwZT0idGV4dC9jc3MiPg0KCS5zdDB7ZmlsbDojNWE1YTVhO30NCjwvc3R5bGU+DQo8Zz4NCgk8dGl0bGU+aWNvbl9kYXRhLXNvdXJjZTwvdGl0bGU+DQoJPGc+DQoJCTxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik0tMTc0LjUsOTQuM2MtNS41LDAtMTAuMi0xLjYtMTIuMy00Yy0wLjEsMC4zLTAuMiwwLjYtMC4yLDFjMCwzLjIsNS43LDYsMTIuNSw2czEyLjUtMi43LDEyLjUtNg0KCQkJYzAtMC4zLTAuMS0wLjctMC4yLTFDLTE2NC40LDkyLjctMTY5LDk0LjMtMTc0LjUsOTQuM3oiLz4NCgkJPHBhdGggY2xhc3M9InN0MCIgZD0iTS0xNzQuNSw4OC45Yy01LjUsMC0xMC4yLTEuNi0xMi4zLTRjLTAuMSwwLjMtMC4yLDAuNi0wLjIsMWMwLDMuMiw1LjcsNiwxMi41LDZzMTIuNS0yLjcsMTIuNS02DQoJCQljMC0wLjMtMC4xLTAuNy0wLjItMUMtMTY0LjQsODcuMy0xNjksODguOS0xNzQuNSw4OC45eiIvPg0KCQk8cGF0aCBjbGFzcz0ic3QwIiBkPSJNLTE4Nyw4MC40YzAsMy4yLDUuNyw2LDEyLjUsNnMxMi41LTIuNywxMi41LTZzLTUuNy02LTEyLjUtNlMtMTg3LDc3LjEtMTg3LDgwLjR6Ii8+DQoJPC9nPg0KPC9nPg0KPC9zdmc+DQo='; + case 'app': + return 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMi45NzgiIGhlaWdodD0iMjUiIHZpZXdCb3g9IjAgMCAyMi45NzggMjUiPjx0aXRsZT5pY29uX2FwcHM8L3RpdGxlPjxwYXRoIGQ9Ik0yMS4yMjQgMTEuMjFhMS43NiAxLjc2IDAgMCAwLTEuNjgyIDEuMjU3SDE0Ljg5YTQuMjMgNC4yMyAwIDAgMC0uMy0xLjE0MmwzLjExNC0xLjhBMi4wNSAyLjA1IDAgMSAwIDE3LjEyIDguMWExLjk4NiAxLjk4NiAwIDAgMCAuMDguNTY1bC0zLjExOCAxLjhhNC4yNDMgNC4yNDMgMCAwIDAtLjgzNS0uODM1bC41ODYtMS4wMTVhMi4xNjUgMi4xNjUgMCAwIDAgLjU5My4wODYgMi4xMTYgMi4xMTYgMCAxIDAtMS40NS0uNThsLS41OCAxLjAxMmEzLjk1NSAzLjk1NSAwIDAgMC0xLjE0LS4zVjMuMDA4YTEuNTQ3IDEuNTQ3IDAgMSAwLTEgMHY1LjgxN2E0LjIzIDQuMjMgMCAwIDAtMS4xNDMuM2wtMi4wNi0zLjU2MkExLjY4NCAxLjY4NCAwIDAgMCA3LjUxIDQuNGExLjcxIDEuNzEgMCAxIDAtMS4zMiAxLjY2bDIuMDYgMy41N2E0LjMyMyA0LjMyMyAwIDAgMC0uODQzLjg0M2wtMy41NjYtMi4wNmExLjc2IDEuNzYgMCAwIDAgLjA0NS0uMzkgMS43IDEuNyAwIDEgMC0xLjcgMS43IDEuNjg2IDEuNjg2IDAgMCAwIDEuMTU1LS40NTNsMy41NyAyLjA2YTQuMDkgNC4wOSAwIDAgMC0uMyAxLjEzM0g1LjIwNmEyLjMwNSAyLjMwNSAwIDEgMCAwIDFoMS40MDdhNC4yMyA0LjIzIDAgMCAwIC4zIDEuMTQyTDMuMTAyIDE2LjhhMS44MjMgMS44MjMgMCAxIDAgLjU1IDEuMyAxLjc3NSAxLjc3NSAwIDAgMC0uMDYzLS40MzhsMy44MjItMi4yMDZhNC4yIDQuMiAwIDAgMCAuODQzLjg0bC0yLjk4IDUuMTkzYTEuNzI3IDEuNzI3IDAgMCAwLS40MTMtLjA1IDEuNzggMS43OCAwIDEgMCAxLjI3Ny41NGwyLjk4LTUuMTc4YTQuMDkgNC4wOSAwIDAgMCAxLjEzMy4zdjEuNDA4YTIuMDU1IDIuMDU1IDAgMSAwIC45OSAwVjE3LjFhNC4yMyA0LjIzIDAgMCAwIDEuMTQzLS4zbDIuNDYgNC4yNmExLjgyNCAxLjgyNCAwIDEgMCAxLjMwNi0uNTUyIDEuNzc4IDEuNzc4IDAgMCAwLS40NDYuMDU3bC0yLjQ2LTQuMjY1YTMuOTYgMy45NiAwIDAgMCAuODI2LS44MjdsLjQ0Ni4yNThhMi4zMjQgMi4zMjQgMCAwIDAtLjEyLjczOCAyLjQgMi40IDAgMSAwIC42Mi0xLjZsLS40NDMtLjI1NGE0LjE1NSA0LjE1NSAwIDAgMCAuMzEtMS4xNTRoNC42NmExLjc1MyAxLjc1MyAwIDEgMCAxLjY4LTIuMjV6bTAgMi43MWEuOTU4Ljk1OCAwIDEgMSAuOTU4LS45NTguOTYuOTYgMCAwIDEtLjk1OC45NnpNMTAuNzUgMTYuMTRhMy4xNzcgMy4xNzcgMCAxIDEgMy4xNzctMy4xNzggMy4xOCAzLjE4IDAgMCAxLTMuMTc3IDMuMTc3em03LjE2My04LjA0YTEuMjYgMS4yNiAwIDEgMSAxLjI2IDEuMjYgMS4yNiAxLjI2IDAgMCAxLTEuMjYtMS4yNnpNMTUuNzQgNi41OTRhMS4zMTQgMS4zMTQgMCAxIDEtMS4zMTUtMS4zMTQgMS4zMTUgMS4zMTUgMCAwIDEgMS4zMTQgMS4zMTR6TTkuOTk2IDEuNTQ4YS43NTMuNzUzIDAgMSAxIC43NTMuNzUzLjc1NC43NTQgMCAwIDEtLjc1My0uNzUyek00Ljg5NyA0LjRhLjkxLjkxIDAgMSAxIC45MS45MS45MS45MSAwIDAgMS0uOTEtLjkxek0yLjE5IDguOTM2YS45MS45MSAwIDEgMSAuOTA4LS45MS45MS45MSAwIDAgMS0uOTEuOTF6bS43NyA1LjUyNmExLjUwNiAxLjUwNiAwIDEgMSAxLjUwNS0xLjUwNiAxLjUwOCAxLjUwOCAwIDAgMS0xLjUwOCAxLjUwNnptLS4xIDMuNjQ2YTEuMDMyIDEuMDMyIDAgMSAxLTEuMDMzLTEuMDMyIDEuMDMzIDEuMDMzIDAgMCAxIDEuMDMyIDEuMDMyem0yLjk4NyA1LjExYS45ODYuOTg2IDAgMSAxLS45ODYtLjk4Ny45ODcuOTg3IDAgMCAxIC45ODcuOTg3em00LjktMS40NmExLjI2IDEuMjYgMCAxIDEgMS4yNi0xLjI2IDEuMjYgMS4yNiAwIDAgMS0xLjI1NyAxLjI2em02LjQ0Mi41N2ExLjAzMiAxLjAzMiAwIDEgMS0xLjAzMy0xLjAyOCAxLjAzMyAxLjAzMyAwIDAgMSAxLjAzMiAxLjAyOHptLS40LTcuNDU4YTEuNiAxLjYgMCAxIDEtMS42IDEuNiAxLjYgMS42IDAgMCAxIDEuNi0xLjZ6IiBmaWxsPSIjODk4OTg5Ii8+PC9zdmc+'; + default: + return undefined; + } + })(typeCode); + + return imageUrl ? ( +
+ ) : null; +}; diff --git a/plugins-bundled/internal/marketplace-app/src/components/SearchField.tsx b/plugins-bundled/internal/marketplace-app/src/components/SearchField.tsx new file mode 100644 index 00000000000..e56490cf636 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/components/SearchField.tsx @@ -0,0 +1,46 @@ +import React, { useState } from 'react'; +import { css } from '@emotion/css'; +import { useStyles2 } from '@grafana/ui'; +import { GrafanaTheme2 } from '@grafana/data'; + +interface Props { + value?: string; + onSearch: (value: string) => void; +} + +export const SearchField = ({ value, onSearch }: Props) => { + const [query, setQuery] = useState(value); + const styles = useStyles2(getStyles); + + return ( + { + if (e.key === 'Enter' || e.keyCode === 13) { + onSearch(e.currentTarget.value); + } + }} + className={styles} + placeholder="Search Grafana plugins" + onChange={(e) => { + setQuery(e.currentTarget.value); + }} + /> + ); +}; + +const getStyles = (theme: GrafanaTheme2) => css` + outline: none; + font-size: 20px; + width: 100%; + border-bottom: 2px solid ${theme.colors.border.weak}; + background: transparent; + line-height: 38px; + font-weight: 400; + padding: ${theme.spacing(0.5)}; + margin: ${theme.spacing(3)} 0; + + &::placeholder { + color: ${theme.colors.action.disabledText}; + } +`; diff --git a/plugins-bundled/internal/marketplace-app/src/components/VersionList.tsx b/plugins-bundled/internal/marketplace-app/src/components/VersionList.tsx new file mode 100644 index 00000000000..a801d4efeb4 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/components/VersionList.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { css } from '@emotion/css'; + +import { dateTimeFormatTimeAgo, GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; +import { Version } from '../types'; + +interface Props { + versions: Version[]; +} + +export const VersionList = ({ versions }: Props) => { + const styles = useStyles2(getStyles); + + return ( +
+ + + + + + + + + {versions.map((version) => { + return ( + + + + + ); + })} + +
VersionLast updated
{version.version}{dateTimeFormatTimeAgo(version.createdAt)}
+
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css` + padding: ${theme.spacing(2, 4, 3)}; + `, + table: css` + width: 100%; + td, + th { + padding: ${theme.spacing()} 0; + } + th { + font-size: ${theme.typography.h5.fontSize}; + } + `, +}); diff --git a/plugins-bundled/internal/marketplace-app/src/config/Settings.tsx b/plugins-bundled/internal/marketplace-app/src/config/Settings.tsx new file mode 100644 index 00000000000..0516c82d876 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/config/Settings.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import { PluginConfigPageProps, AppPluginMeta, PluginMeta } from '@grafana/data'; +import { MarketplaceAppSettings } from 'types'; +import { Button, Field, Legend, Switch } from '@grafana/ui'; +import { api } from '../api'; + +interface Props extends PluginConfigPageProps> {} + +export const Settings = ({ plugin }: Props) => { + const [meta, setMeta] = useState(plugin.meta); + const [state, setState] = useState(meta.jsonData ?? {}); + + const { pinned, enabled } = meta; + const { includeEnterprise } = state; + + const onSave = () => { + updateAndReload('grafana-marketplace-app', { + pinned, + enabled, + jsonData: state, + }); + }; + + return ( + <> + General + + { + setMeta({ ...meta, enabled: e.currentTarget.checked }); + }} + /> + + + { + setMeta({ ...meta, pinned: e.currentTarget.checked }); + }} + /> + + Plugins + + { + setState({ ...state, includeEnterprise: e.currentTarget.checked }); + }} + /> + + + + ); +}; + +const updateAndReload = async (pluginId: string, data: Partial) => { + try { + await api.updatePlugin(pluginId, data); + + // Reloading the page as the changes made here wouldn't be propagated to the actual plugin otherwise. + // This is not ideal, however unfortunately currently there is no supported way for updating the plugin state. + window.location.reload(); + } catch (e) { + console.error('Error while updating the plugin', e); + } +}; diff --git a/plugins-bundled/internal/marketplace-app/src/constants.ts b/plugins-bundled/internal/marketplace-app/src/constants.ts new file mode 100644 index 00000000000..03918117847 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/constants.ts @@ -0,0 +1,3 @@ +export const API_ROOT = '/api/plugins'; +export const PLUGIN_ROOT = '/a/grafana-marketplace-app'; +export const GRAFANA_API_ROOT = '/api/gnet'; diff --git a/plugins-bundled/internal/marketplace-app/src/helpers.ts b/plugins-bundled/internal/marketplace-app/src/helpers.ts new file mode 100644 index 00000000000..7a8c0631c0a --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/helpers.ts @@ -0,0 +1,20 @@ +import { OrgRole } from '@grafana/data'; +import { config } from '@grafana/runtime'; + +export function hasRole(requiredRole: OrgRole): boolean { + const user = config.bootData.user; + switch (requiredRole) { + case OrgRole.Admin: { + return user.orgRole === OrgRole.Admin; + } + case OrgRole.Editor: { + return user.orgRole === OrgRole.Admin || user.orgRole === OrgRole.Editor; + } + case OrgRole.Viewer: { + return user.orgRole === OrgRole.Admin || user.orgRole === OrgRole.Editor || user.orgRole === OrgRole.Viewer; + } + default: { + return false; + } + } +} diff --git a/plugins-bundled/internal/marketplace-app/src/hooks/useHistory.tsx b/plugins-bundled/internal/marketplace-app/src/hooks/useHistory.tsx new file mode 100644 index 00000000000..a33f49298d3 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/hooks/useHistory.tsx @@ -0,0 +1,13 @@ +import { getLocationSrv } from '@grafana/runtime'; + +export const useHistory = () => { + return { + push: ({ query }: any) => { + getLocationSrv().update({ + partial: true, + replace: false, + query, + }); + }, + }; +}; diff --git a/plugins-bundled/internal/marketplace-app/src/hooks/useOrg.tsx b/plugins-bundled/internal/marketplace-app/src/hooks/useOrg.tsx new file mode 100644 index 00000000000..7f4fcac5c82 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/hooks/useOrg.tsx @@ -0,0 +1,24 @@ +import { useState, useEffect } from 'react'; +import { Org } from '../types'; +import { api } from '../api'; + +interface State { + isLoading: boolean; + org?: Org; +} + +export const useOrg = (slug: string): State => { + const [state, setState] = useState({ + isLoading: true, + }); + + useEffect(() => { + const fetchOrgData = async () => { + const org = await api.getOrg(slug); + setState({ org, isLoading: false }); + }; + fetchOrgData(); + }, [slug]); + + return state; +}; diff --git a/plugins-bundled/internal/marketplace-app/src/hooks/usePlugins.tsx b/plugins-bundled/internal/marketplace-app/src/hooks/usePlugins.tsx new file mode 100644 index 00000000000..ec96e8ee126 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/hooks/usePlugins.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; + +import { Plugin, Metadata } from '../types'; +import { api } from '../api'; + +type PluginsState = { + isLoading: boolean; + items: Plugin[]; + installedPlugins: any[]; +}; + +export const usePlugins = (includeEnterprise = false) => { + const [state, setState] = useState({ isLoading: true, items: [], installedPlugins: [] }); + + useEffect(() => { + const fetchPluginData = async () => { + const items = await api.getRemotePlugins(); + const filteredItems = items + .filter((plugin) => Boolean(plugin.versionSignatureType)) + .filter((plugin) => includeEnterprise || plugin.status !== 'enterprise') + .filter((plugin) => !status || plugin.status === status); + const installedPlugins = await api.getInstalledPlugins(); + + setState((state) => ({ ...state, items: filteredItems, installedPlugins, isLoading: false })); + }; + + fetchPluginData(); + }, [includeEnterprise]); + + return state; +}; + +type PluginState = { + isLoading: boolean; + remote?: Plugin; + remoteVersions?: Array<{ version: string; createdAt: string }>; + local?: Metadata; +}; + +export const usePlugin = (slug: string): PluginState => { + const [state, setState] = useState({ + isLoading: true, + }); + + useEffect(() => { + const fetchPluginData = async () => { + const plugin = await api.getPlugin(slug); + setState({ ...plugin, isLoading: false }); + }; + fetchPluginData(); + }, [slug]); + + return state; +}; diff --git a/plugins-bundled/internal/marketplace-app/src/img/browse.png b/plugins-bundled/internal/marketplace-app/src/img/browse.png new file mode 100644 index 00000000000..82ed80bb12c Binary files /dev/null and b/plugins-bundled/internal/marketplace-app/src/img/browse.png differ diff --git a/plugins-bundled/internal/marketplace-app/src/img/details.png b/plugins-bundled/internal/marketplace-app/src/img/details.png new file mode 100644 index 00000000000..15831b8a9f2 Binary files /dev/null and b/plugins-bundled/internal/marketplace-app/src/img/details.png differ diff --git a/plugins-bundled/internal/marketplace-app/src/img/discover.png b/plugins-bundled/internal/marketplace-app/src/img/discover.png new file mode 100644 index 00000000000..183a8e92f75 Binary files /dev/null and b/plugins-bundled/internal/marketplace-app/src/img/discover.png differ diff --git a/plugins-bundled/internal/marketplace-app/src/img/logo.svg b/plugins-bundled/internal/marketplace-app/src/img/logo.svg new file mode 100644 index 00000000000..12a28cb2c32 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/img/logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/plugins-bundled/internal/marketplace-app/src/module.ts b/plugins-bundled/internal/marketplace-app/src/module.ts new file mode 100644 index 00000000000..6b736461e68 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/module.ts @@ -0,0 +1,15 @@ +import { ComponentClass } from 'react'; + +import { AppPlugin, AppPluginMeta, AppRootProps, PluginConfigPageProps } from '@grafana/data'; +import { Settings } from './config/Settings'; +import { MarketplaceRootPage } from './RootPage'; +import { MarketplaceAppSettings } from './types'; + +export const plugin = new AppPlugin() + .setRootPage((MarketplaceRootPage as unknown) as ComponentClass) + .addConfigPage({ + title: 'Settings', + icon: 'info-circle', + body: (Settings as unknown) as ComponentClass>>, + id: 'settings', + }); diff --git a/plugins-bundled/internal/marketplace-app/src/pages/Browse.tsx b/plugins-bundled/internal/marketplace-app/src/pages/Browse.tsx new file mode 100644 index 00000000000..d1dbce627d5 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/pages/Browse.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { AppRootProps, SelectableValue, dateTimeParse } from '@grafana/data'; +import { Field, LoadingPlaceholder, Select } from '@grafana/ui'; + +import { PluginList } from '../components/PluginList'; +import { SearchField } from '../components/SearchField'; +import { HorizontalGroup } from '../components/HorizontalGroup'; +import { usePlugins } from '../hooks/usePlugins'; +import { useHistory } from '../hooks/useHistory'; +import { MarketplaceAppSettings, Plugin } from '../types'; +import { Page } from 'components/Page'; + +export const Browse = ({ query, meta }: AppRootProps) => { + const { q, filterBy, sortBy } = query; + const { includeEnterprise } = meta.jsonData as MarketplaceAppSettings; + + const plugins = usePlugins(includeEnterprise); + const history = useHistory(); + + const onSortByChange = (value: SelectableValue) => { + history.push({ query: { sortBy: value.value } }); + }; + + const onFilterByChange = (value: SelectableValue) => { + history.push({ query: { filterBy: value.value } }); + }; + + const onSearch = (q: any) => { + history.push({ query: { filterBy: null, q } }); + }; + + const filteredPlugins = plugins.items + // Filter by plugin type + .filter((_) => !filterBy || _.typeCode === filterBy || filterBy === 'all') + // Naïve search by checking if any of the properties contains the query string + .filter((plugin) => { + const fields = [plugin.name.toLowerCase(), plugin.orgName.toLowerCase()]; + return !q || fields.some((f) => f.includes(q.toLowerCase())); + }); + + filteredPlugins.sort(sorters[sortBy || 'name']); + + return ( + + + +
+ {plugins.isLoading ? ( + + ) : ( + `${filteredPlugins.length} ${filteredPlugins.length > 1 ? 'results' : 'result'}` + )} +
+ + + +
+ + {!plugins.isLoading && } +
+ ); +}; + +const sorters: { [name: string]: (a: Plugin, b: Plugin) => number } = { + name: (a: Plugin, b: Plugin) => a.name.localeCompare(b.name), + updated: (a: Plugin, b: Plugin) => dateTimeParse(b.updatedAt).valueOf() - dateTimeParse(a.updatedAt).valueOf(), + published: (a: Plugin, b: Plugin) => dateTimeParse(b.createdAt).valueOf() - dateTimeParse(a.createdAt).valueOf(), + downloads: (a: Plugin, b: Plugin) => b.downloads - a.downloads, + popularity: (a: Plugin, b: Plugin) => b.popularity - a.popularity, +}; diff --git a/plugins-bundled/internal/marketplace-app/src/pages/Discover.tsx b/plugins-bundled/internal/marketplace-app/src/pages/Discover.tsx new file mode 100644 index 00000000000..e5cea43266c --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/pages/Discover.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { cx, css } from '@emotion/css'; + +import { dateTimeParse, AppRootProps, GrafanaTheme2 } from '@grafana/data'; +import { useStyles2, Legend, LinkButton } from '@grafana/ui'; + +import { PLUGIN_ROOT } from '../constants'; +import { Card } from '../components/Card'; +import { Grid } from '../components/Grid'; +import { PluginList } from '../components/PluginList'; +import { SearchField } from '../components/SearchField'; +import { PluginTypeIcon } from '../components/PluginTypeIcon'; +import { usePlugins } from '../hooks/usePlugins'; +import { useHistory } from '../hooks/useHistory'; +import { MarketplaceAppSettings, Plugin } from '../types'; +import { Page } from 'components/Page'; +import { Loader } from 'components/Loader'; + +export const Discover = ({ meta }: AppRootProps) => { + const { includeEnterprise } = meta.jsonData as MarketplaceAppSettings; + + const { items, isLoading } = usePlugins(includeEnterprise); + const history = useHistory(); + const styles = useStyles2(getStyles); + + const onSearch = (q: string) => { + history.push({ query: { q, tab: 'browse' } }); + }; + + const featuredPlugins = items.filter((_) => _.featured > 0); + featuredPlugins.sort((a: Plugin, b: Plugin) => { + return b.featured - a.featured; + }); + + const recentlyAdded = items.filter((_) => true); + recentlyAdded.sort((a: Plugin, b: Plugin) => { + const at = dateTimeParse(a.createdAt); + const bt = dateTimeParse(b.createdAt); + return bt.valueOf() - at.valueOf(); + }); + + const mostPopular = items.filter((_) => true); + mostPopular.sort((a: Plugin, b: Plugin) => { + return b.popularity - a.popularity; + }); + + if (isLoading) { + return ; + } + + return ( + + + {/* Featured */} + Featured + + + {/* Most popular */} +
+ Most popular + See more +
+ + + {/* Recently added */} +
+ Recently added + See more +
+ + + {/* Browse by type */} + Browse by type + + } + text={ Panels} + /> + } + text={ Data sources} + /> + } + text={ Apps} + /> + +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + legend: css` + margin-top: ${theme.spacing(4)}; + `, + legendContainer: css` + align-items: baseline; + display: flex; + justify-content: space-between; + `, + typeLegend: css` + align-items: center; + display: flex; + font-size: ${theme.typography.h4.fontSize}; + `, + }; +}; diff --git a/plugins-bundled/internal/marketplace-app/src/pages/Library.tsx b/plugins-bundled/internal/marketplace-app/src/pages/Library.tsx new file mode 100644 index 00000000000..3ba62a92565 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/pages/Library.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { css } from '@emotion/css'; +import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; +import { PLUGIN_ROOT } from '../constants'; +import { PluginList } from '../components/PluginList'; +import { usePlugins } from '../hooks/usePlugins'; +import { Page } from 'components/Page'; +import { Loader } from 'components/Loader'; + +export const Library = () => { + const { isLoading, items, installedPlugins } = usePlugins(true); + const styles = useStyles2(getStyles); + + const filteredPlugins = items.filter((plugin) => !!installedPlugins.find((_) => _.id === plugin.slug)); + + if (isLoading) { + return ; + } + + return ( + +

Library

+ {filteredPlugins.length > 0 ? ( + + ) : ( +

+ You haven't installed any plugins. Browse the{' '} + + marketplace + {' '} + for plugins to install. +

+ )} +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + header: css` + margin-bottom: ${theme.spacing(3)}; + margin-top: ${theme.spacing(3)}; + `, + link: css` + text-decoration: underline; + `, + }; +}; diff --git a/plugins-bundled/internal/marketplace-app/src/pages/OrgDetails.tsx b/plugins-bundled/internal/marketplace-app/src/pages/OrgDetails.tsx new file mode 100644 index 00000000000..648f8a3bea2 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/pages/OrgDetails.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { css } from '@emotion/css'; + +import { AppRootProps, GrafanaTheme2 } from '@grafana/data'; + +import { PluginList } from '../components/PluginList'; +import { usePlugins } from '../hooks/usePlugins'; +import { useOrg } from '../hooks/useOrg'; + +import { useStyles2 } from '@grafana/ui'; +import { Page } from 'components/Page'; +import { Loader } from 'components/Loader'; + +export const OrgDetails = ({ query }: AppRootProps) => { + const { orgSlug } = query; + + const orgData = useOrg(orgSlug); + const { isLoading, items } = usePlugins(); + const styles = useStyles2(getStyles); + + const plugins = items.filter((plugin) => plugin.orgSlug === orgSlug); + + if (isLoading) { + return ; + } + + return ( + +
+ +

{orgData.org?.name}

+
+ +
+ ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + header: css` + align-items: center; + display: flex; + margin-bottom: ${theme.spacing(3)}; + margin-top: ${theme.spacing(3)}; + `, + img: css` + height: 64px; + max-width: 64px; + object-fit: cover; + width: 100%; + `, + orgName: css` + margin-left: ${theme.spacing(3)}; + `, + }; +}; diff --git a/plugins-bundled/internal/marketplace-app/src/pages/PluginDetails.tsx b/plugins-bundled/internal/marketplace-app/src/pages/PluginDetails.tsx new file mode 100644 index 00000000000..dfe5af03057 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/pages/PluginDetails.tsx @@ -0,0 +1,153 @@ +import React, { useState } from 'react'; +import { css } from '@emotion/css'; + +import { AppRootProps, GrafanaTheme2 } from '@grafana/data'; +import { useStyles2, TabsBar, TabContent, Tab, Icon } from '@grafana/ui'; + +import { VersionList } from '../components/VersionList'; +import { InstallControls } from '../components/InstallControls'; +import { PLUGIN_ROOT, GRAFANA_API_ROOT } from '../constants'; +import { usePlugin } from '../hooks/usePlugins'; +import { Page } from 'components/Page'; +import { Loader } from 'components/Loader'; + +export const PluginDetails = ({ query }: AppRootProps) => { + const { slug } = query; + const [tabs, setTabs] = useState([ + { label: 'Overview', active: true }, + { label: 'Version history', active: false }, + ]); + const { isLoading, local, remote, remoteVersions } = usePlugin(slug); + const styles = useStyles2(getStyles); + + const description = remote?.description; + const readme = remote?.readme; + const version = local?.info?.version || remote?.version; + const links = (local?.info?.links || remote?.json?.info?.links) ?? []; + const downloads = remote?.downloads; + + if (isLoading) { + return ; + } + + return ( + +
+ +
+

{remote?.name}

+
+ + {remote?.orgName} + + {links.map((link: any) => ( + + {link.name} + + ))} + {downloads && ( + + + {` ${new Intl.NumberFormat().format(downloads)}`}{' '} + + )} + {version && {version}} +
+

{description}

+ {remote && } +
+
+ + {tabs.map((tab, key) => ( + { + setTabs(tabs.map((tab, index) => ({ ...tab, active: index === key }))); + }} + /> + ))} + + + {tabs.find((_) => _.label === 'Overview')?.active && ( +
+ )} + {tabs.find((_) => _.label === 'Version history')?.active && } + + + ); +}; + +export const getStyles = (theme: GrafanaTheme2) => { + return { + headerContainer: css` + display: flex; + margin-bottom: 24px; + margin-top: 24px; + min-height: 120px; + `, + headerWrapper: css` + margin-left: ${theme.spacing(3)}; + `, + headerLinks: css` + display: flex; + align-items: center; + margin-top: ${theme.spacing()}; + margin-bottom: ${theme.spacing(3)}; + + & > * { + &::after { + content: '|'; + padding: 0 ${theme.spacing()}; + } + } + & > *:last-child { + &::after { + content: ''; + padding-right: 0; + } + } + font-size: ${theme.typography.h4.fontSize}; + `, + headerOrgName: css` + font-size: ${theme.typography.h4.fontSize}; + `, + message: css` + color: ${theme.colors.text.secondary}; + `, + readme: css` + padding: ${theme.spacing(3, 4)}; + + & img { + max-width: 100%; + } + + h1, + h2, + h3 { + margin-top: ${theme.spacing(3)}; + margin-bottom: ${theme.spacing(2)}; + } + + *:first-child { + margin-top: 0; + } + + li { + margin-left: ${theme.spacing(2)}; + & > p { + margin: ${theme.spacing()} 0; + } + } + `, + }; +}; diff --git a/plugins-bundled/internal/marketplace-app/src/pages/index.ts b/plugins-bundled/internal/marketplace-app/src/pages/index.ts new file mode 100644 index 00000000000..294726e7bd7 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/pages/index.ts @@ -0,0 +1,47 @@ +import React from 'react'; +import { AppRootProps } from '@grafana/data'; +import { Discover } from './Discover'; +import { Browse } from './Browse'; +import { PluginDetails } from './PluginDetails'; +import { OrgDetails } from './OrgDetails'; +import { Library } from './Library'; + +export type PageDefinition = { + component: React.FC; + icon: string; + id: string; + text: string; +}; + +export const pages: PageDefinition[] = [ + { + component: Discover, + icon: 'file-alt', + id: 'discover', + text: 'Discover', + }, + { + component: Browse, + icon: 'file-alt', + id: 'browse', + text: 'Browse', + }, + { + component: Library, + icon: 'file-alt', + id: 'library', + text: 'Library', + }, + { + component: PluginDetails, + icon: 'file-alt', + id: 'plugin', + text: 'Plugin', + }, + { + component: OrgDetails, + icon: 'file-alt', + id: 'org', + text: 'Organization', + }, +]; diff --git a/plugins-bundled/internal/marketplace-app/src/plugin.json b/plugins-bundled/internal/marketplace-app/src/plugin.json new file mode 100644 index 00000000000..fc81fe293c7 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/plugin.json @@ -0,0 +1,79 @@ +{ + "$schema": "https://github.com/grafana/grafana/raw/master/docs/sources/developers/plugins/plugin.schema.json", + "type": "app", + "name": "Marketplace", + "id": "grafana-marketplace-app", + "backend": false, + "info": { + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "keywords": ["plugins"], + "logos": { + "small": "img/logo.svg", + "large": "img/logo.svg" + }, + "links": [ + { + "name": "Website", + "url": "https://github.com/grafana/marketplace-app" + }, + { + "name": "License", + "url": "https://github.com/grafana/marketplace-app/blob/master/LICENSE" + } + ], + "screenshots": [ + { + "name": "Discover", + "path": "img/discover.png" + }, + { + "name": "Browse", + "path": "img/browse.png" + }, + { + "name": "Install", + "path": "img/details.png" + } + ], + "version": "%VERSION%", + "updated": "%TODAY%" + }, + "includes": [ + { + "type": "page", + "name": "Discover", + "path": "/a/grafana-marketplace-app?tab=discover", + "role": "Admin", + "addToNav": true, + "defaultNav": true + }, + { + "type": "page", + "name": "Browse", + "path": "/a/grafana-marketplace-app/?tab=browse", + "role": "Admin", + "addToNav": true + }, + { + "type": "page", + "name": "Library", + "path": "/a/grafana-marketplace-app/?tab=library", + "role": "Admin", + "addToNav": true + }, + { + "type": "page", + "name": "View plugin", + "path": "/a/grafana-marketplace-app?tab=plugin", + "role": "Admin" + } + ], + "dependencies": { + "grafanaDependency": ">=7.3.0", + "grafanaVersion": "7.3.x", + "plugins": [] + } +} diff --git a/plugins-bundled/internal/marketplace-app/src/types.ts b/plugins-bundled/internal/marketplace-app/src/types.ts new file mode 100644 index 00000000000..58a023e84b7 --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/src/types.ts @@ -0,0 +1,79 @@ +export interface MarketplaceAppSettings { + includeEnterprise?: boolean; +} + +export type PluginTypeCode = 'app' | 'panel' | 'datasource'; + +export interface Plugin { + name: string; + description: string; + slug: string; + orgName: string; + orgSlug: string; + signatureType: string; + version: string; + status: string; + popularity: number; + downloads: number; + updatedAt: string; + createdAt: string; + typeCode: string; + featured: number; + readme: string; + internal: boolean; + versionSignatureType: string; + packages: { + [arch: string]: { + packageName: string; + downloadUrl: string; + }; + }; + links: Array<{ + rel: string; + href: string; + }>; + json: { + dependencies: { + grafanaDependency: string; + grafanaVersion: string; + }; + info: { + links: Array<{ + name: string; + url: string; + }>; + }; + }; +} + +export type Metadata = { + info: { + version: string; + links: Array<{ + name: string; + url: string; + }>; + }; + dev: boolean; +}; + +export interface Version { + version: string; + createdAt: string; +} + +export interface PluginDetails { + remote?: Plugin; + remoteVersions?: Version[]; + local?: Metadata; +} + +export interface Org { + slug: string; + name: string; + url: string; + createdAt: string; + updatedAt: string; + avatar: string; + avatarUrl: string; +} diff --git a/plugins-bundled/internal/marketplace-app/tsconfig.json b/plugins-bundled/internal/marketplace-app/tsconfig.json new file mode 100644 index 00000000000..cd034409c3c --- /dev/null +++ b/plugins-bundled/internal/marketplace-app/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../packages/grafana-toolkit/src/config/tsconfig.plugin.json", + "include": ["src", "types"], + "compilerOptions": { + "rootDir": "./src", + "baseUrl": "./src", + "typeRoots": ["./node_modules/@types", "../../../node_modules/@types"] + } +} diff --git a/public/app/features/plugins/PluginPage.tsx b/public/app/features/plugins/PluginPage.tsx index 237e151d5ee..dcda8969185 100644 --- a/public/app/features/plugins/PluginPage.tsx +++ b/public/app/features/plugins/PluginPage.tsx @@ -17,13 +17,14 @@ import { PluginSignatureType, PluginType, UrlQueryMap, + PanelPluginMeta, } from '@grafana/data'; import { AppNotificationSeverity } from 'app/types'; import { Alert, LinkButton, PluginSignatureBadge, Tooltip, Badge, useStyles2, Icon } from '@grafana/ui'; import Page from 'app/core/components/Page/Page'; import { getPluginSettings } from './PluginSettingsCache'; -import { importAppPlugin, importDataSourcePlugin, importPanelPlugin } from './plugin_loader'; +import { importAppPlugin, importDataSourcePlugin, importPanelPluginFromMeta } from './plugin_loader'; import { getNotFoundNav } from 'app/core/nav_model_srv'; import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp'; import { AppConfigCtrlWrapper } from './wrappers/AppConfigWrapper'; @@ -501,16 +502,7 @@ export function loadPlugin(pluginId: string): Promise { return importDataSourcePlugin(info); } if (info.type === PluginType.panel) { - return importPanelPlugin(pluginId).then((plugin) => { - // Panel Meta does not have the *full* settings meta - return getPluginSettings(pluginId).then((meta) => { - plugin.meta = { - ...meta, // Set any fields that do not exist - ...plugin.meta, - }; - return plugin; - }); - }); + return importPanelPluginFromMeta(info as PanelPluginMeta); } if (info.type === PluginType.renderer) { return Promise.resolve({ meta: info } as GrafanaPlugin); diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index 74f47bddaf2..3c4b138e09d 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -236,7 +236,17 @@ export function importPanelPlugin(id: string): Promise throw new Error(`Plugin ${id} not found`); } - panelCache[id] = importPluginModule(meta.module) + panelCache[id] = getPanelPlugin(meta); + + return panelCache[id]; +} + +export function importPanelPluginFromMeta(meta: grafanaData.PanelPluginMeta): Promise { + return getPanelPlugin(meta); +} + +function getPanelPlugin(meta: grafanaData.PanelPluginMeta): Promise { + return importPluginModule(meta.module) .then((pluginExports) => { if (pluginExports.plugin) { return pluginExports.plugin as grafanaData.PanelPlugin; @@ -253,9 +263,7 @@ export function importPanelPlugin(id: string): Promise }) .catch((err) => { // TODO, maybe a different error plugin - console.warn('Error loading panel plugin: ' + id, err); + console.warn('Error loading panel plugin: ' + meta.id, err); return getPanelPluginLoadError(meta, err); }); - - return panelCache[id]; } diff --git a/yarn.lock b/yarn.lock index 0a3a94e693d..01851e23a82 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6111,6 +6111,11 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-6.0.2.tgz#5e8b09f0e4af53034b1d0fb9977a277847836205" integrity sha512-G1Ggy7/9Nsa1Jt2yiBR2riEuyK2DFNnqow6R7cromXPMNynackRY1vqFTLz/gwnef1LHokbXThcPhqMRjUbkpQ== +"@types/semver@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.3.4.tgz#43d7168fec6fa0988bb1a513a697b29296721afb" + integrity sha512-+nVsLKlcUCeMzD2ufHEYuJ9a2ovstb6Dp52A5VsoKxDXgvE051XgHI/33I1EymwkRGQkwnA0LkhnUzituGs4EQ== + "@types/serve-static@*": version "1.13.3" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.3.tgz#eb7e1c41c4468272557e897e9171ded5e2ded9d1"