Plugin Admin App: make the catalog look like internal component (#34341)

* Allow Route component usage in app plugins

* i tried

* fix catalog app

* fix catalog app

* fix catalog app

* cleanup imports

* plugin catalog enabled to plugin admin

* rename plugin catalog to plugin admin

* expose catalog url

* update text

* import from react-router-dom

* fix imports -- add logging

* merge changes

* avoid onNavUpdate

* Fixed onNavChange issues

* fix library imports

* more links

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
pull/34484/head
Ryan McKinley 4 years ago committed by GitHub
parent 95ee5f01b5
commit a91edd7267
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      conf/defaults.ini
  2. 6
      conf/sample.ini
  3. 11
      docs/sources/administration/configuration.md
  4. 3
      packages/grafana-runtime/src/config.ts
  5. 2
      pkg/api/api.go
  6. 7
      pkg/api/frontendsettings.go
  7. 5
      pkg/plugins/app_plugin.go
  8. 6
      pkg/plugins/manager/manager_test.go
  9. 8
      pkg/setting/setting.go
  10. 2
      pkg/tests/api/plugins/api_install_test.go
  11. 6
      pkg/tests/testinfra/testinfra.go
  12. 3
      plugins-bundled/internal/plugin-admin-app/CHANGELOG.md
  13. 3
      plugins-bundled/internal/plugin-admin-app/README.md
  14. 0
      plugins-bundled/internal/plugin-admin-app/jest.config.js
  15. 8
      plugins-bundled/internal/plugin-admin-app/package.json
  16. 58
      plugins-bundled/internal/plugin-admin-app/src/RootPage.tsx
  17. 0
      plugins-bundled/internal/plugin-admin-app/src/api.ts
  18. 2
      plugins-bundled/internal/plugin-admin-app/src/components/Card.tsx
  19. 0
      plugins-bundled/internal/plugin-admin-app/src/components/Grid.tsx
  20. 0
      plugins-bundled/internal/plugin-admin-app/src/components/HorizontalGroup.tsx
  21. 9
      plugins-bundled/internal/plugin-admin-app/src/components/InstallControls.tsx
  22. 0
      plugins-bundled/internal/plugin-admin-app/src/components/Loader.tsx
  23. 0
      plugins-bundled/internal/plugin-admin-app/src/components/Page.tsx
  24. 2
      plugins-bundled/internal/plugin-admin-app/src/components/PluginList.tsx
  25. 0
      plugins-bundled/internal/plugin-admin-app/src/components/PluginTypeIcon.tsx
  26. 0
      plugins-bundled/internal/plugin-admin-app/src/components/SearchField.tsx
  27. 0
      plugins-bundled/internal/plugin-admin-app/src/components/VersionList.tsx
  28. 19
      plugins-bundled/internal/plugin-admin-app/src/config/Settings.tsx
  29. 2
      plugins-bundled/internal/plugin-admin-app/src/constants.ts
  30. 0
      plugins-bundled/internal/plugin-admin-app/src/helpers.ts
  31. 0
      plugins-bundled/internal/plugin-admin-app/src/hooks/useHistory.tsx
  32. 0
      plugins-bundled/internal/plugin-admin-app/src/hooks/usePlugins.tsx
  33. 0
      plugins-bundled/internal/plugin-admin-app/src/img/logo.svg
  34. 10
      plugins-bundled/internal/plugin-admin-app/src/module.ts
  35. 18
      plugins-bundled/internal/plugin-admin-app/src/pages/Browse.tsx
  36. 22
      plugins-bundled/internal/plugin-admin-app/src/pages/Discover.tsx
  37. 13
      plugins-bundled/internal/plugin-admin-app/src/pages/Library.tsx
  38. 23
      plugins-bundled/internal/plugin-admin-app/src/pages/NotEnabed.tsx
  39. 21
      plugins-bundled/internal/plugin-admin-app/src/pages/PluginDetails.tsx
  40. 7
      plugins-bundled/internal/plugin-admin-app/src/pages/index.ts
  41. 67
      plugins-bundled/internal/plugin-admin-app/src/pages/nav.ts
  42. 29
      plugins-bundled/internal/plugin-admin-app/src/plugin.json
  43. 0
      plugins-bundled/internal/plugin-admin-app/src/types.ts
  44. 0
      plugins-bundled/internal/plugin-admin-app/tsconfig.json
  45. 5
      plugins-bundled/internal/plugin-catalog-app/README.md
  46. 15
      plugins-bundled/internal/plugin-catalog-app/src/RootPage.tsx
  47. 24
      plugins-bundled/internal/plugin-catalog-app/src/hooks/useOrg.tsx
  48. BIN
      plugins-bundled/internal/plugin-catalog-app/src/img/browse.png
  49. BIN
      plugins-bundled/internal/plugin-catalog-app/src/img/details.png
  50. BIN
      plugins-bundled/internal/plugin-catalog-app/src/img/discover.png
  51. 6
      plugins-bundled/internal/plugin-catalog-app/src/module.ts
  52. 56
      plugins-bundled/internal/plugin-catalog-app/src/pages/OrgDetails.tsx
  53. 64
      plugins-bundled/internal/plugin-catalog-app/src/plugin.json
  54. 2
      public/app/features/datasources/state/buildCategories.ts
  55. 1
      public/app/features/plugins/AppRootPage.tsx
  56. 10
      public/app/features/plugins/PluginListPage.tsx

@ -880,9 +880,9 @@ enable_alpha = false
app_tls_skip_verify_insecure = false app_tls_skip_verify_insecure = false
# Enter a comma-separated list of plugin identifiers to identify plugins that are allowed to be loaded even if they lack a valid signature. # Enter a comma-separated list of plugin identifiers to identify plugins that are allowed to be loaded even if they lack a valid signature.
allow_loading_unsigned_plugins = allow_loading_unsigned_plugins =
catalog_url = https://grafana.com/grafana/plugins/ # Enable or disable installing plugins directly from within Grafana.
# Enable or disable the Marketplace app which can be used to manage plugins from within Grafana. plugin_admin_enabled = false
catalog_app_enabled = false plugin_catalog_url = https://grafana.com/grafana/plugins/
#################################### Grafana Image Renderer Plugin ########################## #################################### Grafana Image Renderer Plugin ##########################
[plugin.grafana-image-renderer] [plugin.grafana-image-renderer]

@ -866,9 +866,9 @@
;app_tls_skip_verify_insecure = false ;app_tls_skip_verify_insecure = false
# Enter a comma-separated list of plugin identifiers to identify plugins that are allowed to be loaded even if they lack a valid signature. # Enter a comma-separated list of plugin identifiers to identify plugins that are allowed to be loaded even if they lack a valid signature.
;allow_loading_unsigned_plugins = ;allow_loading_unsigned_plugins =
;catalog_url = https://grafana.com/grafana/plugins/ # Enable or disable installing plugins directly from within Grafana.
# Enable or disable the Marketplace app which can be used to manage plugins from within Grafana. ;plugin_admin_enabled = false
;catalog_app_enabled = false ;plugin_catalog_url = https://grafana.com/grafana/plugins/
#################################### Grafana Image Renderer Plugin ########################## #################################### Grafana Image Renderer Plugin ##########################
[plugin.grafana-image-renderer] [plugin.grafana-image-renderer]

@ -1471,16 +1471,17 @@ Set to `true` if you want to test alpha plugins that are not yet ready for gener
Enter a comma-separated list of plugin identifiers to identify plugins that are allowed to be loaded even if they lack a valid signature. Enter a comma-separated list of plugin identifiers to identify plugins that are allowed to be loaded even if they lack a valid signature.
### catalog_url ### plugin_admin_enabled
Custom install/learn more URL for enterprise plugins. Defaults to https://grafana.com/grafana/plugins/.
### catalog_app_enabled Available to Grafana administrators only, the plugin admin app is set to `false` by default. Set it to `true` to enable the app.
For more information, refer to [Plugin catalog]({{< relref "../plugins/catalog.md" >}}).
Available to Grafana administrators only, the plugin catalog app is set to `false` by default. Set it to `true` to enable the app.
For more information, refer to [Plugin catalog]({{< relref "../plugins/catalog.md" >}}). ### plugin_catalog_url
Custom install/learn more URL for enterprise plugins. Defaults to https://grafana.com/grafana/plugins/.
<hr> <hr>

@ -74,7 +74,8 @@ export class GrafanaBootConfig implements GrafanaConfig {
customEndpoint: '', customEndpoint: '',
sampleRate: 1, sampleRate: 1,
}; };
catalogUrl?: string; pluginCatalogURL = 'https://grafana.com/grafana/plugins/';
pluginAdminEnabled = false;
expressionsEnabled = false; expressionsEnabled = false;
customTheme?: any; customTheme?: any;
awsAllowedAuthProviders: string[] = []; awsAllowedAuthProviders: string[] = [];

@ -284,7 +284,7 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Any("/plugins/:pluginId/resources/*", hs.CallResource) apiRoute.Any("/plugins/:pluginId/resources/*", hs.CallResource)
apiRoute.Get("/plugins/errors", routing.Wrap(hs.GetPluginErrorsList)) apiRoute.Get("/plugins/errors", routing.Wrap(hs.GetPluginErrorsList))
if hs.Cfg.CatalogAppEnabled { if hs.Cfg.PluginAdminEnabled {
apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) { apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) {
pluginRoute.Post("/:pluginId/install", bind(dtos.InstallPluginCommand{}), routing.Wrap(hs.InstallPlugin)) pluginRoute.Post("/:pluginId/install", bind(dtos.InstallPluginCommand{}), routing.Wrap(hs.InstallPlugin))
pluginRoute.Post("/:pluginId/uninstall", routing.Wrap(hs.UninstallPlugin)) pluginRoute.Post("/:pluginId/uninstall", routing.Wrap(hs.UninstallPlugin))

@ -129,11 +129,15 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i
return nil, err return nil, err
} }
hasPluginManagerApp := false
pluginsToPreload := []string{} pluginsToPreload := []string{}
for _, app := range enabledPlugins.Apps { for _, app := range enabledPlugins.Apps {
if app.Preload { if app.Preload {
pluginsToPreload = append(pluginsToPreload, app.Module) pluginsToPreload = append(pluginsToPreload, app.Module)
} }
if app.Id == "grafana-plugin-admin-app" {
hasPluginManagerApp = true
}
} }
dataSources, err := hs.getFSDataSources(c, enabledPlugins) dataSources, err := hs.getFSDataSources(c, enabledPlugins)
@ -242,7 +246,8 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i
"rendererAvailable": hs.RenderService.IsAvailable(), "rendererAvailable": hs.RenderService.IsAvailable(),
"http2Enabled": hs.Cfg.Protocol == setting.HTTP2Scheme, "http2Enabled": hs.Cfg.Protocol == setting.HTTP2Scheme,
"sentry": hs.Cfg.Sentry, "sentry": hs.Cfg.Sentry,
"catalogUrl": hs.Cfg.CatalogURL, "pluginCatalogURL": hs.Cfg.PluginCatalogURL,
"pluginAdminEnabled": c.HasUserRole(models.ROLE_ADMIN) && hs.Cfg.PluginAdminEnabled && hasPluginManagerApp,
"expressionsEnabled": hs.Cfg.ExpressionsEnabled, "expressionsEnabled": hs.Cfg.ExpressionsEnabled,
"awsAllowedAuthProviders": hs.Cfg.AWSAllowedAuthProviders, "awsAllowedAuthProviders": hs.Cfg.AWSAllowedAuthProviders,
"awsAssumeRoleEnabled": hs.Cfg.AWSAssumeRoleEnabled, "awsAssumeRoleEnabled": hs.Cfg.AWSAssumeRoleEnabled,

@ -84,11 +84,6 @@ func (app *AppPlugin) InitApp(panels map[string]*PanelPlugin, dataSources map[st
cfg *setting.Cfg) []*PluginStaticRoute { cfg *setting.Cfg) []*PluginStaticRoute {
staticRoutes := app.InitFrontendPlugin(cfg) staticRoutes := app.InitFrontendPlugin(cfg)
// force enable bundled catalog app
if app.Id == "grafana-plugin-catalog-app" && cfg.CatalogAppEnabled {
app.AutoEnabled = true
}
// check if we have child panels // check if we have child panels
for _, panel := range panels { for _, panel := range panels {
if strings.HasPrefix(panel.PluginDir, app.PluginDir) { if strings.HasPrefix(panel.PluginDir, app.PluginDir) {

@ -533,8 +533,8 @@ func verifyBundledPluginCatalogue(t *testing.T, pm *PluginManager) {
t.Helper() t.Helper()
bundledPlugins := map[string]string{ bundledPlugins := map[string]string{
"input": "input-datasource", "input": "input-datasource",
"grafana-plugin-catalog-app": "plugin-catalog-app", "grafana-plugin-admin-app": "plugin-admin-app",
} }
for pluginID, pluginDir := range bundledPlugins { for pluginID, pluginDir := range bundledPlugins {
@ -547,7 +547,7 @@ func verifyBundledPluginCatalogue(t *testing.T, pm *PluginManager) {
} }
assert.NotNil(t, pm.dataSources["input"]) assert.NotNil(t, pm.dataSources["input"])
assert.NotNil(t, pm.apps["grafana-plugin-catalog-app"]) assert.NotNil(t, pm.apps["grafana-plugin-admin-app"])
} }
type fakeBackendPluginManager struct { type fakeBackendPluginManager struct {

@ -257,8 +257,8 @@ type Cfg struct {
PluginsAppsSkipVerifyTLS bool PluginsAppsSkipVerifyTLS bool
PluginSettings PluginSettings PluginSettings PluginSettings
PluginsAllowUnsigned []string PluginsAllowUnsigned []string
CatalogURL string PluginCatalogURL string
CatalogAppEnabled bool PluginAdminEnabled bool
DisableSanitizeHtml bool DisableSanitizeHtml bool
EnterpriseLicensePath string EnterpriseLicensePath string
@ -892,8 +892,8 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
plug = strings.TrimSpace(plug) plug = strings.TrimSpace(plug)
cfg.PluginsAllowUnsigned = append(cfg.PluginsAllowUnsigned, plug) cfg.PluginsAllowUnsigned = append(cfg.PluginsAllowUnsigned, plug)
} }
cfg.CatalogURL = pluginsSection.Key("catalog_url").MustString("https://grafana.com/grafana/plugins/") cfg.PluginCatalogURL = pluginsSection.Key("plugin_catalog_url").MustString("https://grafana.com/grafana/plugins/")
cfg.CatalogAppEnabled = pluginsSection.Key("catalog_app_enabled").MustBool(false) cfg.PluginAdminEnabled = pluginsSection.Key("plugin_admin_enabled").MustBool(false)
// Read and populate feature toggles list // Read and populate feature toggles list
featureTogglesSection := iniFile.Section("feature_toggles") featureTogglesSection := iniFile.Section("feature_toggles")

@ -26,7 +26,7 @@ const (
func TestPluginInstallAccess(t *testing.T) { func TestPluginInstallAccess(t *testing.T) {
dir, cfgPath := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ dir, cfgPath := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
CatalogAppEnabled: true, PluginAdminEnabled: true,
}) })
store := testinfra.SetUpDatabase(t, dir) store := testinfra.SetUpDatabase(t, dir)
store.Bus = bus.GetBus() // in order to allow successful user auth store.Bus = bus.GetBus() // in order to allow successful user auth

@ -233,10 +233,10 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) {
_, err = anonSect.NewKey("enabled", "false") _, err = anonSect.NewKey("enabled", "false")
require.NoError(t, err) require.NoError(t, err)
} }
if o.CatalogAppEnabled { if o.PluginAdminEnabled {
anonSect, err := cfg.NewSection("plugins") anonSect, err := cfg.NewSection("plugins")
require.NoError(t, err) require.NoError(t, err)
_, err = anonSect.NewKey("catalog_app_enabled", "true") _, err = anonSect.NewKey("plugin_admin_enabled", "true")
require.NoError(t, err) require.NoError(t, err)
} }
} }
@ -257,5 +257,5 @@ type GrafanaOpts struct {
AnonymousUserRole models.RoleType AnonymousUserRole models.RoleType
EnableQuota bool EnableQuota bool
DisableAnonymous bool DisableAnonymous bool
CatalogAppEnabled bool PluginAdminEnabled bool
} }

@ -0,0 +1,3 @@
# Change Log
Changes are included in the grafana core changelog

@ -0,0 +1,3 @@
# Grafana admin app
The grafana catalog is enabled or disabled by setting `plugin_admin_enabled` in the setup files.

@ -1,7 +1,7 @@
{ {
"name": "@grafana-plugins/catalog-app", "name": "@grafana-plugins/admin-app",
"version": "8.1.0-pre", "version": "8.1.0-pre",
"description": "Plugins catalog", "description": "Plugins admin",
"private": true, "private": true,
"repository": { "repository": {
"type": "git", "type": "git",
@ -18,9 +18,7 @@
"@grafana/data": "8.1.0-pre", "@grafana/data": "8.1.0-pre",
"@grafana/runtime": "8.1.0-pre", "@grafana/runtime": "8.1.0-pre",
"@grafana/toolkit": "8.1.0-pre", "@grafana/toolkit": "8.1.0-pre",
"@grafana/ui": "8.1.0-pre", "@grafana/ui": "8.1.0-pre"
"@types/semver": "^7.3.4",
"semver": "^7.3.4"
}, },
"volta": { "volta": {
"node": "12.16.2" "node": "12.16.2"

@ -0,0 +1,58 @@
import { AppRootProps } from '@grafana/data';
import React from 'react';
import { Discover } from 'pages/Discover';
import { Browse } from 'pages/Browse';
import { PluginDetails } from 'pages/PluginDetails';
import { Library } from 'pages/Library';
import { Route } from 'react-router-dom';
import { config } from '@grafana/runtime';
import { NotEnabled } from 'pages/NotEnabed';
export const CatalogRootPage = React.memo(function CatalogRootPage(props: AppRootProps) {
if (!config.pluginAdminEnabled) {
return <NotEnabled {...props} />;
}
return (
<>
<Route
exact
path={`${props.basename}`}
render={() => {
return <Browse {...props} />; // or discover?
}}
/>
<Route
exact
path={`${props.basename}/browse`}
render={() => {
return <Browse {...props} />;
}}
/>
<Route
exact
path={`${props.basename}/discover`}
render={() => {
return <Discover {...props} />;
}}
/>
<Route
path={`${props.basename}/plugin/:pluginId`}
render={() => {
return <PluginDetails {...props} />;
}}
/>
<Route
exact
path={`${props.basename}/library`}
render={() => {
return <Library {...props} />;
}}
/>
</>
);
});

@ -35,7 +35,7 @@ export const Card = ({ href, text, image, layout = 'vertical' }: Props) => {
const getCardStyles = (theme: GrafanaTheme2) => ({ const getCardStyles = (theme: GrafanaTheme2) => ({
root: css` root: css`
background-color: ${theme.colors.background.primary}; background-color: ${theme.colors.background.secondary};
border-radius: ${theme.shape.borderRadius()}; border-radius: ${theme.shape.borderRadius()};
cursor: pointer; cursor: pointer;
height: 100%; height: 100%;

@ -17,10 +17,9 @@ import appEvents from 'grafana/app/core/app_events';
interface Props { interface Props {
localPlugin?: Metadata; localPlugin?: Metadata;
remotePlugin: Plugin; remotePlugin: Plugin;
slug: string;
} }
export const InstallControls = ({ localPlugin, remotePlugin, slug }: Props) => { export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [isInstalled, setIsInstalled] = useState(Boolean(localPlugin)); const [isInstalled, setIsInstalled] = useState(Boolean(localPlugin));
const [shouldUpdate, setShouldUpdate] = useState( const [shouldUpdate, setShouldUpdate] = useState(
@ -32,7 +31,7 @@ export const InstallControls = ({ localPlugin, remotePlugin, slug }: Props) => {
const onInstall = async () => { const onInstall = async () => {
setLoading(true); setLoading(true);
try { try {
await api.installPlugin(slug, remotePlugin.version); await api.installPlugin(remotePlugin.slug, remotePlugin.version);
appEvents.emit(AppEvents.alertSuccess, [`Installed ${remotePlugin?.name}`]); appEvents.emit(AppEvents.alertSuccess, [`Installed ${remotePlugin?.name}`]);
setLoading(false); setLoading(false);
setIsInstalled(true); setIsInstalled(true);
@ -44,7 +43,7 @@ export const InstallControls = ({ localPlugin, remotePlugin, slug }: Props) => {
const onUninstall = async () => { const onUninstall = async () => {
setLoading(true); setLoading(true);
try { try {
await api.uninstallPlugin(slug); await api.uninstallPlugin(remotePlugin.slug);
appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${remotePlugin?.name}`]); appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${remotePlugin?.name}`]);
setLoading(false); setLoading(false);
setIsInstalled(false); setIsInstalled(false);
@ -56,7 +55,7 @@ export const InstallControls = ({ localPlugin, remotePlugin, slug }: Props) => {
const onUpdate = async () => { const onUpdate = async () => {
setLoading(true); setLoading(true);
try { try {
await api.installPlugin(slug, remotePlugin.version); await api.installPlugin(remotePlugin.slug, remotePlugin.version);
appEvents.emit(AppEvents.alertSuccess, [`Updated ${remotePlugin?.name}`]); appEvents.emit(AppEvents.alertSuccess, [`Updated ${remotePlugin?.name}`]);
setLoading(false); setLoading(false);
setShouldUpdate(false); setShouldUpdate(false);

@ -23,7 +23,7 @@ export const PluginList = ({ plugins }: Props) => {
return ( return (
<Card <Card
key={`${orgName}-${name}-${typeCode}`} key={`${orgName}-${name}-${typeCode}`}
href={`${PLUGIN_ROOT}?tab=plugin&slug=${slug}`} href={`${PLUGIN_ROOT}/plugin/${slug}`}
image={ image={
<img <img
src={`https://grafana.com/api/plugins/${slug}/versions/${version}/logos/small`} src={`https://grafana.com/api/plugins/${slug}/versions/${version}/logos/small`}

@ -0,0 +1,19 @@
import React from 'react';
import { PluginConfigPageProps, AppPluginMeta } from '@grafana/data';
import { LinkButton } from '@grafana/ui';
import { PLUGIN_ROOT } from '../constants';
import { config } from '@grafana/runtime';
interface Props extends PluginConfigPageProps<AppPluginMeta> {}
export const Settings = ({ plugin }: Props) => {
if (!config.pluginAdminEnabled) {
return <div>Plugin admin is not enabled.</div>;
}
return (
<>
<LinkButton href={PLUGIN_ROOT}>Manage plugins</LinkButton>
</>
);
};

@ -1,4 +1,4 @@
export const API_ROOT = '/api/plugins'; export const API_ROOT = '/api/plugins';
export const PLUGIN_ID = 'grafana-plugin-catalog-app'; export const PLUGIN_ID = 'grafana-plugin-admin-app';
export const PLUGIN_ROOT = '/a/' + PLUGIN_ID; export const PLUGIN_ROOT = '/a/' + PLUGIN_ID;
export const GRAFANA_API_ROOT = '/api/gnet'; export const GRAFANA_API_ROOT = '/api/gnet';

@ -0,0 +1,10 @@
import { AppPlugin } from '@grafana/data';
import { Settings } from './config/Settings';
import { CatalogRootPage } from './RootPage';
export const plugin = new AppPlugin().setRootPage(CatalogRootPage as any).addConfigPage({
title: 'Settings',
icon: 'info-circle',
body: Settings as any,
id: 'settings',
});

@ -1,7 +1,9 @@
import React from 'react'; import React, { useEffect } from 'react';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { AppRootProps, SelectableValue, dateTimeParse } from '@grafana/data'; import { AppRootProps, SelectableValue, dateTimeParse } from '@grafana/data';
import { Field, LoadingPlaceholder, Select } from '@grafana/ui'; import { Field, LoadingPlaceholder, Select } from '@grafana/ui';
import { useLocation } from 'react-router-dom';
import { locationSearchToObject } from '@grafana/runtime';
import { PluginList } from '../components/PluginList'; import { PluginList } from '../components/PluginList';
import { SearchField } from '../components/SearchField'; import { SearchField } from '../components/SearchField';
@ -10,13 +12,23 @@ import { usePlugins } from '../hooks/usePlugins';
import { useHistory } from '../hooks/useHistory'; import { useHistory } from '../hooks/useHistory';
import { Plugin } from '../types'; import { Plugin } from '../types';
import { Page } from 'components/Page'; import { Page } from 'components/Page';
import { CatalogTab, getCatalogNavModel } from './nav';
export const Browse = ({ query }: AppRootProps) => { export const Browse = ({ meta, onNavChanged, basename }: AppRootProps) => {
const { q, filterBy, sortBy } = query; const location = useLocation();
const query = locationSearchToObject(location.search);
const q = query.q as string;
const filterBy = query.filterBy as string;
const sortBy = query.sortBy as string;
const plugins = usePlugins(); const plugins = usePlugins();
const history = useHistory(); const history = useHistory();
useEffect(() => {
onNavChanged(getCatalogNavModel(CatalogTab.Browse, basename));
}, [onNavChanged, basename]);
const onSortByChange = (value: SelectableValue<string>) => { const onSortByChange = (value: SelectableValue<string>) => {
history.push({ query: { sortBy: value.value } }); history.push({ query: { sortBy: value.value } });
}; };

@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import { cx, css } from '@emotion/css'; import { cx, css } from '@emotion/css';
import { dateTimeParse, GrafanaTheme2 } from '@grafana/data'; import { dateTimeParse, AppRootProps, GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Legend, LinkButton } from '@grafana/ui'; import { useStyles2, Legend, LinkButton } from '@grafana/ui';
import { locationService } from '@grafana/runtime';
import { PLUGIN_ROOT } from '../constants'; import { PLUGIN_ROOT } from '../constants';
import { Card } from '../components/Card'; import { Card } from '../components/Card';
@ -11,18 +12,19 @@ import { PluginList } from '../components/PluginList';
import { SearchField } from '../components/SearchField'; import { SearchField } from '../components/SearchField';
import { PluginTypeIcon } from '../components/PluginTypeIcon'; import { PluginTypeIcon } from '../components/PluginTypeIcon';
import { usePlugins } from '../hooks/usePlugins'; import { usePlugins } from '../hooks/usePlugins';
import { useHistory } from '../hooks/useHistory';
import { Plugin } from '../types'; import { Plugin } from '../types';
import { Page } from 'components/Page'; import { Page } from 'components/Page';
import { Loader } from 'components/Loader'; import { Loader } from 'components/Loader';
export const Discover = () => { export const Discover = ({ meta }: AppRootProps) => {
const { items, isLoading } = usePlugins(); const { items, isLoading } = usePlugins();
const history = useHistory();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const onSearch = (q: string) => { const onSearch = (q: string) => {
history.push({ query: { q, tab: 'browse' } }); locationService.push({
pathname: `${PLUGIN_ROOT}/browse`,
search: `?q=${q}`,
});
}; };
const featuredPlugins = items.filter((_) => _.featured > 0); const featuredPlugins = items.filter((_) => _.featured > 0);
@ -56,14 +58,14 @@ export const Discover = () => {
{/* Most popular */} {/* Most popular */}
<div className={styles.legendContainer}> <div className={styles.legendContainer}>
<Legend className={styles.legend}>Most popular</Legend> <Legend className={styles.legend}>Most popular</Legend>
<LinkButton href={`${PLUGIN_ROOT}?tab=browse&sortBy=popularity`}>See more</LinkButton> <LinkButton href={`${PLUGIN_ROOT}/browse?sortBy=popularity`}>See more</LinkButton>
</div> </div>
<PluginList plugins={mostPopular.slice(0, 5)} /> <PluginList plugins={mostPopular.slice(0, 5)} />
{/* Recently added */} {/* Recently added */}
<div className={styles.legendContainer}> <div className={styles.legendContainer}>
<Legend className={styles.legend}>Recently added</Legend> <Legend className={styles.legend}>Recently added</Legend>
<LinkButton href={`${PLUGIN_ROOT}?tab=browse&sortBy=published'`}>See more</LinkButton> <LinkButton href={`${PLUGIN_ROOT}/browse?sortBy=published'`}>See more</LinkButton>
</div> </div>
<PluginList plugins={recentlyAdded.slice(0, 5)} /> <PluginList plugins={recentlyAdded.slice(0, 5)} />
@ -72,19 +74,19 @@ export const Discover = () => {
<Grid> <Grid>
<Card <Card
layout="horizontal" layout="horizontal"
href={`${PLUGIN_ROOT}?tab=browse&filterBy=panel`} href={`${PLUGIN_ROOT}/browse?filterBy=panel`}
image={<PluginTypeIcon typeCode="panel" size={18} />} image={<PluginTypeIcon typeCode="panel" size={18} />}
text={<span className={styles.typeLegend}>&nbsp;Panels</span>} text={<span className={styles.typeLegend}>&nbsp;Panels</span>}
/> />
<Card <Card
layout="horizontal" layout="horizontal"
href={`${PLUGIN_ROOT}?tab=browse&filterBy=datasource`} href={`${PLUGIN_ROOT}/browse?filterBy=datasource`}
image={<PluginTypeIcon typeCode="datasource" size={18} />} image={<PluginTypeIcon typeCode="datasource" size={18} />}
text={<span className={styles.typeLegend}>&nbsp;Data sources</span>} text={<span className={styles.typeLegend}>&nbsp;Data sources</span>}
/> />
<Card <Card
layout="horizontal" layout="horizontal"
href={`${PLUGIN_ROOT}?tab=browse&filterBy=app`} href={`${PLUGIN_ROOT}/browse?filterBy=app`}
image={<PluginTypeIcon typeCode="app" size={18} />} image={<PluginTypeIcon typeCode="app" size={18} />}
text={<span className={styles.typeLegend}>&nbsp;Apps</span>} text={<span className={styles.typeLegend}>&nbsp;Apps</span>}
/> />

@ -1,17 +1,22 @@
import React from 'react'; import React, { useEffect } from 'react';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data'; import { AppRootProps, GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui';
import { PLUGIN_ROOT } from '../constants'; import { PLUGIN_ROOT } from '../constants';
import { PluginList } from '../components/PluginList'; import { PluginList } from '../components/PluginList';
import { usePlugins } from '../hooks/usePlugins'; import { usePlugins } from '../hooks/usePlugins';
import { Page } from 'components/Page'; import { Page } from 'components/Page';
import { Loader } from 'components/Loader'; import { Loader } from 'components/Loader';
import { CatalogTab, getCatalogNavModel } from './nav';
export const Library = () => { export const Library = ({ meta, onNavChanged, basename }: AppRootProps) => {
const { isLoading, items, installedPlugins } = usePlugins(); const { isLoading, items, installedPlugins } = usePlugins();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
useEffect(() => {
onNavChanged(getCatalogNavModel(CatalogTab.Browse, basename));
}, [onNavChanged, basename]);
const filteredPlugins = items.filter((plugin) => !!installedPlugins.find((_) => _.id === plugin.slug)); const filteredPlugins = items.filter((plugin) => !!installedPlugins.find((_) => _.id === plugin.slug));
if (isLoading) { if (isLoading) {
@ -26,7 +31,7 @@ export const Library = () => {
) : ( ) : (
<p> <p>
You haven&#39;t installed any plugins. Browse the{' '} You haven&#39;t installed any plugins. Browse the{' '}
<a className={styles.link} href={`${PLUGIN_ROOT}/?tab=browse&sortBy=popularity`}> <a className={styles.link} href={`${PLUGIN_ROOT}/browse?sortBy=popularity`}>
catalog catalog
</a>{' '} </a>{' '}
for plugins to install. for plugins to install.

@ -0,0 +1,23 @@
import React from 'react';
import { Page } from 'components/Page';
import { AppRootProps, NavModelItem } from '@grafana/data';
export const NotEnabled = ({ onNavChanged }: AppRootProps) => {
const node: NavModelItem = {
id: 'not-found',
text: 'The plugin catalog is not enabled',
icon: 'exclamation-triangle',
url: 'not-found',
};
onNavChanged({
node: node,
main: node,
});
return (
<Page>
To enabled installing plugins, set the{' '}
<a href="https://grafana.com/docs/grafana/latest/plugins/catalog">Plugin Catalog</a> instructions
</Page>
);
};

@ -1,8 +1,9 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
import { AppRootProps, GrafanaTheme2 } from '@grafana/data'; import { AppRootProps, GrafanaTheme2 } from '@grafana/data';
import { useStyles2, TabsBar, TabContent, Tab, Icon } from '@grafana/ui'; import { useStyles2, TabsBar, TabContent, Tab, Icon } from '@grafana/ui';
import { useParams } from 'react-router-dom';
import { VersionList } from '../components/VersionList'; import { VersionList } from '../components/VersionList';
import { InstallControls } from '../components/InstallControls'; import { InstallControls } from '../components/InstallControls';
@ -11,13 +12,15 @@ import { usePlugin } from '../hooks/usePlugins';
import { Page } from 'components/Page'; import { Page } from 'components/Page';
import { Loader } from 'components/Loader'; import { Loader } from 'components/Loader';
export const PluginDetails = ({ query }: AppRootProps) => { export const PluginDetails = ({ onNavChanged }: AppRootProps) => {
const { slug } = query; const { pluginId } = useParams<{ pluginId: string }>();
const [tabs, setTabs] = useState([ const [tabs, setTabs] = useState([
{ label: 'Overview', active: true }, { label: 'Overview', active: true },
{ label: 'Version history', active: false }, { label: 'Version history', active: false },
]); ]);
const { isLoading, local, remote, remoteVersions } = usePlugin(slug);
const { isLoading, local, remote, remoteVersions } = usePlugin(pluginId);
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const description = remote?.description; const description = remote?.description;
@ -26,6 +29,10 @@ export const PluginDetails = ({ query }: AppRootProps) => {
const links = (local?.info?.links || remote?.json?.info?.links) ?? []; const links = (local?.info?.links || remote?.json?.info?.links) ?? [];
const downloads = remote?.downloads; const downloads = remote?.downloads;
useEffect(() => {
onNavChanged(undefined as any);
}, [onNavChanged]);
if (isLoading) { if (isLoading) {
return <Loader />; return <Loader />;
} }
@ -34,7 +41,7 @@ export const PluginDetails = ({ query }: AppRootProps) => {
<Page> <Page>
<div className={styles.headerContainer}> <div className={styles.headerContainer}>
<img <img
src={`${GRAFANA_API_ROOT}/plugins/${slug}/versions/${remote?.version}/logos/small`} src={`${GRAFANA_API_ROOT}/plugins/${pluginId}/versions/${remote?.version}/logos/small`}
className={css` className={css`
object-fit: cover; object-fit: cover;
width: 100%; width: 100%;
@ -45,7 +52,7 @@ export const PluginDetails = ({ query }: AppRootProps) => {
<div className={styles.headerWrapper}> <div className={styles.headerWrapper}>
<h1>{remote?.name}</h1> <h1>{remote?.name}</h1>
<div className={styles.headerLinks}> <div className={styles.headerLinks}>
<a className={styles.headerOrgName} href={`${PLUGIN_ROOT}?tab=org&orgSlug=${remote?.orgSlug}`}> <a className={styles.headerOrgName} href={`${PLUGIN_ROOT}`}>
{remote?.orgName} {remote?.orgName}
</a> </a>
{links.map((link: any) => ( {links.map((link: any) => (
@ -62,7 +69,7 @@ export const PluginDetails = ({ query }: AppRootProps) => {
{version && <span>{version}</span>} {version && <span>{version}</span>}
</div> </div>
<p>{description}</p> <p>{description}</p>
{remote && <InstallControls localPlugin={local} remotePlugin={remote} slug={slug} />} {remote && <InstallControls localPlugin={local} remotePlugin={remote} />}
</div> </div>
</div> </div>
<TabsBar> <TabsBar>

@ -3,7 +3,6 @@ import { AppRootProps } from '@grafana/data';
import { Discover } from './Discover'; import { Discover } from './Discover';
import { Browse } from './Browse'; import { Browse } from './Browse';
import { PluginDetails } from './PluginDetails'; import { PluginDetails } from './PluginDetails';
import { OrgDetails } from './OrgDetails';
import { Library } from './Library'; import { Library } from './Library';
export type PageDefinition = { export type PageDefinition = {
@ -38,10 +37,4 @@ export const pages: PageDefinition[] = [
id: 'plugin', id: 'plugin',
text: 'Plugin', text: 'Plugin',
}, },
{
component: OrgDetails,
icon: 'file-alt',
id: 'org',
text: 'Organization',
},
]; ];

@ -0,0 +1,67 @@
import { NavModel, NavModelItem } from '@grafana/data';
export enum CatalogTab {
Browse = 'browse',
Discover = 'discover',
Library = 'library',
}
export function getCatalogNavModel(tab: CatalogTab, baseURL: string): NavModel {
const pages: NavModelItem[] = [];
if (!baseURL.endsWith('/')) {
baseURL += '/';
}
pages.push({
text: 'Browse',
icon: 'icon-gf icon-gf-apps',
url: `${baseURL}`,
id: CatalogTab.Browse,
});
// pages.push({
// text: 'Discover',
// icon: 'file-alt',
// url: `${baseURL}${CatalogTab.Discover}`,
// id: CatalogTab.Discover,
// });
pages.push({
text: 'Library',
icon: 'icon-gf icon-gf-apps',
url: `${baseURL}${CatalogTab.Library}`,
id: CatalogTab.Library,
});
const node: NavModelItem = {
text: 'Catalog',
icon: 'cog',
subTitle: 'Manage plugin installations',
breadcrumbs: [{ title: 'Plugins', url: 'plugins' }],
children: setActivePage(tab, pages, CatalogTab.Browse),
};
return {
node: node,
main: node,
};
}
function setActivePage(pageId: CatalogTab, pages: NavModelItem[], defaultPageId: CatalogTab): NavModelItem[] {
let found = false;
const selected = pageId || defaultPageId;
const changed = pages.map((p) => {
const active = !found && selected === p.id;
if (active) {
found = true;
}
return { ...p, active };
});
if (!found) {
changed[0].active = true;
}
return changed;
}

@ -0,0 +1,29 @@
{
"$schema": "https://github.com/grafana/grafana/raw/master/docs/sources/developers/plugins/plugin.schema.json",
"type": "app",
"name": "Plugin Admin",
"id": "grafana-plugin-admin-app",
"backend": false,
"autoEnabled": true,
"pinned": false,
"info": {
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
"keywords": ["plugins"],
"logos": {
"small": "img/logo.svg",
"large": "img/logo.svg"
},
"links": [],
"screenshots": [],
"version": "%VERSION%",
"updated": "%TODAY%"
},
"dependencies": {
"grafanaDependency": ">=8.0.0",
"grafanaVersion": "8.0.x",
"plugins": []
}
}

@ -1,5 +0,0 @@
# Grafana plugin catalog
Allow Admin users to browse and manage plugins from within Grafana.
This plugin is **included** with Grafana however it is only accessible if [enabled in Grafana settings](https://grafana.com/docs/grafana/next/administration/configuration/#catalog_app_enabled).

@ -1,15 +0,0 @@
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 <Page {...props} path={pathWithoutLeadingSlash} />;
});

@ -1,24 +0,0 @@
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<State>({
isLoading: true,
});
useEffect(() => {
const fetchOrgData = async () => {
const org = await api.getOrg(slug);
setState({ org, isLoading: false });
};
fetchOrgData();
}, [slug]);
return state;
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 509 KiB

@ -1,6 +0,0 @@
import { ComponentClass } from 'react';
import { AppPlugin, AppRootProps } from '@grafana/data';
import { MarketplaceRootPage } from './RootPage';
export const plugin = new AppPlugin().setRootPage((MarketplaceRootPage as unknown) as ComponentClass<AppRootProps>);

@ -1,56 +0,0 @@
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 <Loader />;
}
return (
<Page>
<div className={styles.header}>
<img src={orgData.org?.avatarUrl} className={styles.img} />
<h1 className={styles.orgName}>{orgData.org?.name}</h1>
</div>
<PluginList plugins={plugins} />
</Page>
);
};
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)};
`,
};
};

@ -1,64 +0,0 @@
{
"$schema": "https://github.com/grafana/grafana/raw/master/docs/sources/developers/plugins/plugin.schema.json",
"type": "app",
"name": "Plugin Catalog",
"id": "grafana-plugin-catalog-app",
"backend": false,
"info": {
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
"keywords": ["plugins"],
"logos": {
"small": "img/logo.svg",
"large": "img/logo.svg"
},
"links": [],
"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-plugin-catalog-app?tab=discover",
"role": "Admin",
"addToNav": true,
"defaultNav": true
},
{
"type": "page",
"name": "Browse",
"path": "/a/grafana-plugin-catalog-app/?tab=browse",
"role": "Admin",
"addToNav": true
},
{
"type": "page",
"name": "Library",
"path": "/a/grafana-plugin-catalog-app/?tab=library",
"role": "Admin",
"addToNav": true
}
],
"dependencies": {
"grafanaDependency": ">=8.0.0",
"grafanaVersion": "8.0.x",
"plugins": []
}
}

@ -203,7 +203,7 @@ function getPhantomPlugin(options: GetPhantomPluginOptions): DataSourcePluginMet
author: { name: 'Grafana Labs' }, author: { name: 'Grafana Labs' },
links: [ links: [
{ {
url: config.catalogUrl + options.id, url: config.pluginCatalogURL + options.id,
name: 'Install now', name: 'Install now',
}, },
], ],

@ -86,6 +86,7 @@ class AppRootPage extends Component<Props, State> {
} }
onNavChanged = (nav: NavModel) => { onNavChanged = (nav: NavModel) => {
console.log('NAV CHANGED!!!', nav);
this.setState({ nav }); this.setState({ nav });
}; };

@ -12,6 +12,7 @@ import { setPluginsSearchQuery } from './state/reducers';
import { useAsync } from 'react-use'; import { useAsync } from 'react-use';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { PluginsErrorsInfo } from './PluginsErrorsInfo'; import { PluginsErrorsInfo } from './PluginsErrorsInfo';
import { config } from '@grafana/runtime';
const mapStateToProps = (state: StoreState) => ({ const mapStateToProps = (state: StoreState) => ({
navModel: getNavModel(state.navIndex, 'plugins'), navModel: getNavModel(state.navIndex, 'plugins'),
@ -40,11 +41,18 @@ export const PluginListPage: React.FC<Props> = ({
loadPlugins(); loadPlugins();
}, [loadPlugins]); }, [loadPlugins]);
let actionTarget: string | undefined = '_blank';
const linkButton = { const linkButton = {
href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list', href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list',
title: 'Find more plugins on Grafana.com', title: 'Find more plugins on Grafana.com',
}; };
if (config.pluginAdminEnabled) {
linkButton.href = '/a/grafana-plugin-admin-app/';
linkButton.title = 'Install & manage plugins';
actionTarget = undefined;
}
return ( return (
<Page navModel={navModel} aria-label={selectors.pages.PluginsList.page}> <Page navModel={navModel} aria-label={selectors.pages.PluginsList.page}>
<Page.Contents isLoading={!hasFetched}> <Page.Contents isLoading={!hasFetched}>
@ -54,7 +62,7 @@ export const PluginListPage: React.FC<Props> = ({
setSearchQuery={(query) => setPluginsSearchQuery(query)} setSearchQuery={(query) => setPluginsSearchQuery(query)}
linkButton={linkButton} linkButton={linkButton}
placeholder="Search by name, author, description or type" placeholder="Search by name, author, description or type"
target="_blank" target={actionTarget}
/> />
<PluginsErrorsInfo> <PluginsErrorsInfo>

Loading…
Cancel
Save