Plugins: converted the plugins admin app to a core feature in grafana (#36026)

* moved the plugins admin to core and used the plugins toggle to decide which version to use.

* reverted change.

* changed so the library tab is the default one.

* fixing navigation.
#

* fixed so we have the proper header.

* including the core plugins

* fixed so we display logos for local plugins.

* fixed so we have a working version of plugin catalog.

* removed console logs.

* reverted changes.

* fixing failed test.
pull/36591/head
Marcus Andersson 5 years ago committed by GitHub
parent 6266a9e77a
commit 52bd1eb1c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      pkg/api/index.go
  2. 8
      pkg/plugins/manager/manager_test.go
  3. 3
      plugins-bundled/internal/plugin-admin-app/CHANGELOG.md
  4. 3
      plugins-bundled/internal/plugin-admin-app/README.md
  5. 8
      plugins-bundled/internal/plugin-admin-app/jest.config.js
  6. 26
      plugins-bundled/internal/plugin-admin-app/package.json
  7. 58
      plugins-bundled/internal/plugin-admin-app/src/RootPage.tsx
  8. 19
      plugins-bundled/internal/plugin-admin-app/src/config/Settings.tsx
  9. 4
      plugins-bundled/internal/plugin-admin-app/src/constants.ts
  10. 58
      plugins-bundled/internal/plugin-admin-app/src/hooks/usePlugins.tsx
  11. 10
      plugins-bundled/internal/plugin-admin-app/src/module.ts
  12. 111
      plugins-bundled/internal/plugin-admin-app/src/pages/Browse.tsx
  13. 114
      plugins-bundled/internal/plugin-admin-app/src/pages/Discover.tsx
  14. 54
      plugins-bundled/internal/plugin-admin-app/src/pages/Library.tsx
  15. 32
      plugins-bundled/internal/plugin-admin-app/src/pages/NotEnabed.tsx
  16. 160
      plugins-bundled/internal/plugin-admin-app/src/pages/PluginDetails.tsx
  17. 40
      plugins-bundled/internal/plugin-admin-app/src/pages/index.ts
  18. 29
      plugins-bundled/internal/plugin-admin-app/src/plugin.json
  19. 9
      plugins-bundled/internal/plugin-admin-app/tsconfig.json
  20. 7
      public/app/features/plugins/PluginListPage.tsx
  21. 35
      public/app/features/plugins/admin/api.ts
  22. 0
      public/app/features/plugins/admin/components/Card.tsx
  23. 0
      public/app/features/plugins/admin/components/Grid.tsx
  24. 0
      public/app/features/plugins/admin/components/HorizontalGroup.tsx
  25. 6
      public/app/features/plugins/admin/components/InstallControls.tsx
  26. 0
      public/app/features/plugins/admin/components/Loader.tsx
  27. 0
      public/app/features/plugins/admin/components/Page.tsx
  28. 19
      public/app/features/plugins/admin/components/PluginList.tsx
  29. 24
      public/app/features/plugins/admin/components/PluginLogo.tsx
  30. 2
      public/app/features/plugins/admin/components/PluginTypeIcon.tsx
  31. 0
      public/app/features/plugins/admin/components/SearchField.tsx
  32. 4
      public/app/features/plugins/admin/components/VersionList.tsx
  33. 2
      public/app/features/plugins/admin/constants.ts
  34. 6
      public/app/features/plugins/admin/guards.ts
  35. 0
      public/app/features/plugins/admin/helpers.ts
  36. 0
      public/app/features/plugins/admin/hooks/useHistory.tsx
  37. 111
      public/app/features/plugins/admin/hooks/usePlugins.tsx
  38. 0
      public/app/features/plugins/admin/img/logo.svg
  39. 103
      public/app/features/plugins/admin/pages/Browse.tsx
  40. 124
      public/app/features/plugins/admin/pages/Discover.tsx
  41. 60
      public/app/features/plugins/admin/pages/Library.tsx
  42. 35
      public/app/features/plugins/admin/pages/NotEnabed.tsx
  43. 167
      public/app/features/plugins/admin/pages/PluginDetails.tsx
  44. 9
      public/app/features/plugins/admin/pages/nav.ts
  45. 32
      public/app/features/plugins/admin/types.ts
  46. 39
      public/app/features/plugins/routes.ts
  47. 18
      public/app/plugins/panel/pluginlist/PluginList.tsx
  48. 13
      public/app/routes/routes.tsx

@ -376,14 +376,6 @@ func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink {
})
}
if c.IsGrafanaAdmin {
if hs.Cfg.PluginAdminEnabled {
adminNavLinks = append(adminNavLinks, &dtos.NavLink{
Text: "Plugin catalog", Id: "plugin-catalog", Url: hs.Cfg.AppSubURL + "/a/grafana-plugin-admin-app", Icon: "plug",
})
}
}
if hs.Cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.ActionLDAPStatusRead) {
adminNavLinks = append(adminNavLinks, &dtos.NavLink{
Text: "LDAP", Id: "ldap", Url: hs.Cfg.AppSubURL + "/admin/ldap", Icon: "book",

@ -40,7 +40,7 @@ func TestPluginManager_Init(t *testing.T) {
assert.Empty(t, pm.scanningErrors)
verifyCorePluginCatalogue(t, pm)
verifyBundledPluginCatalogue(t, pm)
verifyBundledPlugins(t, pm)
})
t.Run("Base case with single external plugin", func(t *testing.T) {
@ -607,12 +607,11 @@ func verifyCorePluginCatalogue(t *testing.T, pm *PluginManager) {
}
}
func verifyBundledPluginCatalogue(t *testing.T, pm *PluginManager) {
func verifyBundledPlugins(t *testing.T, pm *PluginManager) {
t.Helper()
bundledPlugins := map[string]string{
"input": "input-datasource",
"grafana-plugin-admin-app": "plugin-admin-app",
"input": "input-datasource",
}
for pluginID, pluginDir := range bundledPlugins {
@ -625,7 +624,6 @@ func verifyBundledPluginCatalogue(t *testing.T, pm *PluginManager) {
}
assert.NotNil(t, pm.dataSources["input"])
assert.NotNil(t, pm.apps["grafana-plugin-admin-app"])
}
type fakeBackendPluginManager struct {

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

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

@ -1,8 +0,0 @@
// 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();

@ -1,26 +0,0 @@
{
"name": "@grafana-plugins/admin-app",
"version": "8.1.0-pre",
"description": "Plugins admin",
"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",
"devDependencies": {
"@grafana/data": "8.1.0-pre",
"@grafana/runtime": "8.1.0-pre",
"@grafana/toolkit": "8.1.0-pre",
"@grafana/ui": "8.1.0-pre"
},
"volta": {
"extends": "../../../package.json"
}
}

@ -1,58 +0,0 @@
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} />;
}}
/>
</>
);
});

@ -1,19 +0,0 @@
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 +0,0 @@
export const API_ROOT = '/api/plugins';
export const PLUGIN_ID = 'grafana-plugin-admin-app';
export const PLUGIN_ROOT = '/a/' + PLUGIN_ID;
export const GRAFANA_API_ROOT = '/api/gnet';

@ -1,58 +0,0 @@
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 = () => {
const [state, setState] = useState<PluginsState>({ isLoading: true, items: [], installedPlugins: [] });
useEffect(() => {
const fetchPluginData = async () => {
const items = await api.getRemotePlugins();
const filteredPlugins = items.filter((plugin) => {
const isNotRenderer = plugin.typeCode !== 'renderer';
const isSigned = Boolean(plugin.versionSignatureType);
const isNotEnterprise = plugin.status !== 'enterprise';
return isNotRenderer && isSigned && isNotEnterprise;
});
const installedPlugins = await api.getInstalledPlugins();
setState((state) => ({ ...state, items: filteredPlugins, installedPlugins, isLoading: false }));
};
fetchPluginData();
}, []);
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<PluginState>({
isLoading: true,
});
useEffect(() => {
const fetchPluginData = async () => {
const plugin = await api.getPlugin(slug);
setState({ ...plugin, isLoading: false });
};
fetchPluginData();
}, [slug]);
return state;
};

@ -1,10 +0,0 @@
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,111 +0,0 @@
import React, { useEffect } from 'react';
import { css } from '@emotion/css';
import { AppRootProps, SelectableValue, dateTimeParse } from '@grafana/data';
import { Field, LoadingPlaceholder, Select } from '@grafana/ui';
import { useLocation } from 'react-router-dom';
import { locationSearchToObject } from '@grafana/runtime';
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 { Plugin } from '../types';
import { Page } from 'components/Page';
import { CatalogTab, getCatalogNavModel } from './nav';
export const Browse = ({ meta, onNavChanged, basename }: AppRootProps) => {
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 history = useHistory();
useEffect(() => {
onNavChanged(getCatalogNavModel(CatalogTab.Browse, basename));
}, [onNavChanged, basename]);
const onSortByChange = (value: SelectableValue<string>) => {
history.push({ query: { sortBy: value.value } });
};
const onFilterByChange = (value: SelectableValue<string>) => {
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 (
<Page>
<SearchField value={q} onSearch={onSearch} />
<HorizontalGroup>
<div>
{plugins.isLoading ? (
<LoadingPlaceholder
className={css`
margin-bottom: 0;
`}
text="Loading results"
/>
) : (
`${filteredPlugins.length} ${filteredPlugins.length > 1 ? 'results' : 'result'}`
)}
</div>
<Field label="Show">
<Select
width={15}
value={filterBy || 'all'}
onChange={onFilterByChange}
options={[
{ value: 'all', label: 'All' },
{ value: 'panel', label: 'Panels' },
{ value: 'datasource', label: 'Data sources' },
{ value: 'app', label: 'Apps' },
]}
/>
</Field>
<Field label="Sort by">
<Select
width={20}
value={sortBy || 'name'}
onChange={onSortByChange}
options={[
{ value: 'name', label: 'Name' },
{ value: 'popularity', label: 'Popularity' },
{ value: 'updated', label: 'Updated date' },
{ value: 'published', label: 'Published date' },
{ value: 'downloads', label: 'Downloads' },
]}
/>
</Field>
</HorizontalGroup>
{!plugins.isLoading && <PluginList plugins={filteredPlugins} />}
</Page>
);
};
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,
};

@ -1,114 +0,0 @@
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 { locationService } from '@grafana/runtime';
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 { Plugin } from '../types';
import { Page } from 'components/Page';
import { Loader } from 'components/Loader';
export const Discover = ({ meta }: AppRootProps) => {
const { items, isLoading } = usePlugins();
const styles = useStyles2(getStyles);
const onSearch = (q: string) => {
locationService.push({
pathname: `${PLUGIN_ROOT}/browse`,
search: `?q=${q}`,
});
};
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 <Loader />;
}
return (
<Page>
<SearchField onSearch={onSearch} />
{/* Featured */}
<Legend className={styles.legend}>Featured</Legend>
<PluginList plugins={featuredPlugins.slice(0, 5)} />
{/* Most popular */}
<div className={styles.legendContainer}>
<Legend className={styles.legend}>Most popular</Legend>
<LinkButton href={`${PLUGIN_ROOT}/browse?sortBy=popularity`}>See more</LinkButton>
</div>
<PluginList plugins={mostPopular.slice(0, 5)} />
{/* Recently added */}
<div className={styles.legendContainer}>
<Legend className={styles.legend}>Recently added</Legend>
<LinkButton href={`${PLUGIN_ROOT}/browse?sortBy=published'`}>See more</LinkButton>
</div>
<PluginList plugins={recentlyAdded.slice(0, 5)} />
{/* Browse by type */}
<Legend className={cx(styles.legend)}>Browse by type</Legend>
<Grid>
<Card
layout="horizontal"
href={`${PLUGIN_ROOT}/browse?filterBy=panel`}
image={<PluginTypeIcon typeCode="panel" size={18} />}
text={<span className={styles.typeLegend}>&nbsp;Panels</span>}
/>
<Card
layout="horizontal"
href={`${PLUGIN_ROOT}/browse?filterBy=datasource`}
image={<PluginTypeIcon typeCode="datasource" size={18} />}
text={<span className={styles.typeLegend}>&nbsp;Data sources</span>}
/>
<Card
layout="horizontal"
href={`${PLUGIN_ROOT}/browse?filterBy=app`}
image={<PluginTypeIcon typeCode="app" size={18} />}
text={<span className={styles.typeLegend}>&nbsp;Apps</span>}
/>
</Grid>
</Page>
);
};
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};
`,
};
};

@ -1,54 +0,0 @@
import React, { useEffect } from 'react';
import { css } from '@emotion/css';
import { AppRootProps, 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';
import { CatalogTab, getCatalogNavModel } from './nav';
export const Library = ({ meta, onNavChanged, basename }: AppRootProps) => {
const { isLoading, items, installedPlugins } = usePlugins();
const styles = useStyles2(getStyles);
useEffect(() => {
onNavChanged(getCatalogNavModel(CatalogTab.Library, basename));
}, [onNavChanged, basename]);
const filteredPlugins = items.filter((plugin) => !!installedPlugins.find((_) => _.id === plugin.slug));
if (isLoading) {
return <Loader />;
}
return (
<Page>
<h1 className={styles.header}>Library</h1>
{filteredPlugins.length > 0 ? (
<PluginList plugins={filteredPlugins} />
) : (
<p>
You haven&#39;t installed any plugins. Browse the{' '}
<a className={styles.link} href={`${PLUGIN_ROOT}/browse?sortBy=popularity`}>
catalog
</a>{' '}
for plugins to install.
</p>
)}
</Page>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
header: css`
margin-bottom: ${theme.spacing(3)};
margin-top: ${theme.spacing(3)};
`,
link: css`
text-decoration: underline;
`,
};
};

@ -1,32 +0,0 @@
import React from 'react';
import { Page } from 'components/Page';
import { AppRootProps, NavModelItem } from '@grafana/data';
import { css } from '@emotion/css';
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 enable installing plugins, please refer to the{' '}
<a
className={css`
text-decoration: underline;
`}
href="https://grafana.com/docs/grafana/latest/plugins/catalog"
>
Plugin Catalog
</a>{' '}
instructions
</Page>
);
};

@ -1,160 +0,0 @@
import React, { useEffect, useState } from 'react';
import { css } from '@emotion/css';
import { AppRootProps, GrafanaTheme2 } from '@grafana/data';
import { useStyles2, TabsBar, TabContent, Tab, Icon } from '@grafana/ui';
import { useParams } from 'react-router-dom';
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 = ({ onNavChanged }: AppRootProps) => {
const { pluginId } = useParams<{ pluginId: string }>();
const [tabs, setTabs] = useState([
{ label: 'Overview', active: true },
{ label: 'Version history', active: false },
]);
const { isLoading, local, remote, remoteVersions } = usePlugin(pluginId);
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;
useEffect(() => {
onNavChanged(undefined as any);
}, [onNavChanged]);
if (isLoading) {
return <Loader />;
}
return (
<Page>
<div className={styles.headerContainer}>
<img
src={`${GRAFANA_API_ROOT}/plugins/${pluginId}/versions/${remote?.version}/logos/small`}
className={css`
object-fit: cover;
width: 100%;
height: 68px;
max-width: 68px;
`}
/>
<div className={styles.headerWrapper}>
<h1>{remote?.name}</h1>
<div className={styles.headerLinks}>
<a className={styles.headerOrgName} href={`${PLUGIN_ROOT}`}>
{remote?.orgName}
</a>
{links.map((link: any) => (
<a key={link.name} href={link.url}>
{link.name}
</a>
))}
{downloads && (
<span>
<Icon name="cloud-download" />
{` ${new Intl.NumberFormat().format(downloads)}`}{' '}
</span>
)}
{version && <span>{version}</span>}
</div>
<p>{description}</p>
{remote && <InstallControls localPlugin={local} remotePlugin={remote} />}
</div>
</div>
<TabsBar>
{tabs.map((tab, key) => (
<Tab
key={key}
label={tab.label}
active={tab.active}
onChangeTab={() => {
setTabs(tabs.map((tab, index) => ({ ...tab, active: index === key })));
}}
/>
))}
</TabsBar>
<TabContent>
{tabs.find((_) => _.label === 'Overview')?.active && (
<div className={styles.readme} dangerouslySetInnerHTML={{ __html: readme ?? '' }} />
)}
{tabs.find((_) => _.label === 'Version history')?.active && <VersionList versions={remoteVersions ?? []} />}
</TabContent>
</Page>
);
};
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;
}
}
`,
};
};

@ -1,40 +0,0 @@
import React from 'react';
import { AppRootProps } from '@grafana/data';
import { Discover } from './Discover';
import { Browse } from './Browse';
import { PluginDetails } from './PluginDetails';
import { Library } from './Library';
export type PageDefinition = {
component: React.FC<AppRootProps>;
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',
},
];

@ -1,29 +0,0 @@
{
"$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,9 +0,0 @@
{
"extends": "../../../packages/grafana-toolkit/src/config/tsconfig.plugin.json",
"include": ["src", "types"],
"compilerOptions": {
"rootDir": "./src",
"baseUrl": "./src",
"typeRoots": ["./node_modules/@types", "../../../node_modules/@types"]
}
}

@ -12,7 +12,6 @@ import { setPluginsSearchQuery } from './state/reducers';
import { useAsync } from 'react-use';
import { selectors } from '@grafana/e2e-selectors';
import { PluginsErrorsInfo } from './PluginsErrorsInfo';
import { config } from '@grafana/runtime';
const mapStateToProps = (state: StoreState) => ({
navModel: getNavModel(state.navIndex, 'plugins'),
@ -47,12 +46,6 @@ export const PluginListPage: React.FC<Props> = ({
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 (
<Page navModel={navModel} aria-label={selectors.pages.PluginsList.page}>
<Page.Contents isLoading={!hasFetched}>

@ -1,7 +1,7 @@
import { getBackendSrv } from '@grafana/runtime';
import { PluginMeta } from '@grafana/data';
import { API_ROOT, GRAFANA_API_ROOT } from './constants';
import { Plugin, PluginDetails, Org } from './types';
import { Plugin, PluginDetails, Org, LocalPlugin } from './types';
async function getRemotePlugins(): Promise<Plugin[]> {
const res = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins`);
@ -9,29 +9,42 @@ async function getRemotePlugins(): Promise<Plugin[]> {
}
async function getPlugin(slug: string): Promise<PluginDetails> {
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;
const localPlugin = installed?.find((plugin: LocalPlugin) => {
return plugin.id === slug;
});
const [remote, versions] = await Promise.all([getRemotePlugin(slug, localPlugin), getPluginVersions(slug)]);
return {
remote: res,
remote: remote,
remoteVersions: versions,
local: plugin,
local: localPlugin,
};
}
async function getRemotePlugin(slug: string, local: LocalPlugin | undefined): Promise<Plugin | undefined> {
try {
return await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${slug}`);
} catch (error) {
// this might be a plugin that doesn't exist on gcom.
error.isHandled = !!local;
return;
}
}
async function getPluginVersions(id: string): Promise<any[]> {
const versions = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${id}/versions`);
return versions.items;
try {
const versions = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${id}/versions`);
return versions.items;
} catch (error) {
return [];
}
}
async function getInstalledPlugins(): Promise<any> {
const installed = await getBackendSrv().get(`${API_ROOT}?core=0`);
const installed = await getBackendSrv().get(`${API_ROOT}`);
return installed;
}

@ -6,16 +6,16 @@ import { config } from '@grafana/runtime';
import { Button, HorizontalGroup, Icon, LinkButton, useStyles2 } from '@grafana/ui';
import { AppEvents, GrafanaTheme2 } from '@grafana/data';
import { Metadata, Plugin } from '../types';
import { LocalPlugin, Plugin } from '../types';
import { api } from '../api';
// This isn't exported in the sdk yet
// @ts-ignore
import appEvents from 'grafana/app/core/app_events';
import appEvents from 'app/core/app_events';
import { isGrafanaAdmin } from '../helpers';
interface Props {
localPlugin?: Metadata;
localPlugin?: LocalPlugin;
remotePlugin: Plugin;
}

@ -4,9 +4,10 @@ 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';
import { isLocalPlugin } from '../guards';
import { PluginLogo } from './PluginLogo';
interface Props {
plugins: Plugin[];
@ -18,15 +19,14 @@ export const PluginList = ({ plugins }: Props) => {
return (
<Grid>
{plugins.map((plugin) => {
const { name, slug, version, orgName, typeCode } = plugin;
const { name, orgName, typeCode } = plugin;
return (
<Card
key={`${orgName}-${name}-${typeCode}`}
href={`${PLUGIN_ROOT}/plugin/${slug}`}
href={`/plugins/${getPluginId(plugin)}`}
image={
<img
src={`https://grafana.com/api/plugins/${slug}/versions/${version}/logos/small`}
<PluginLogo
plugin={plugin}
className={css`
max-height: 64px;
`}
@ -45,6 +45,13 @@ export const PluginList = ({ plugins }: Props) => {
);
};
function getPluginId(plugin: Plugin): string {
if (isLocalPlugin(plugin)) {
return plugin.id;
}
return plugin.slug;
}
const getStyles = (theme: GrafanaTheme2) => ({
name: css`
font-size: ${theme.typography.h4.fontSize};

@ -0,0 +1,24 @@
import React from 'react';
import { isLocalPlugin } from '../guards';
import { LocalPlugin, Plugin } from '../types';
type PluginLogoProps = {
plugin: Plugin | LocalPlugin | undefined;
className?: string;
};
export function PluginLogo({ plugin, className }: PluginLogoProps): React.ReactElement | null {
return <img src={getImageSrc(plugin)} className={className} />;
}
function getImageSrc(plugin: Plugin | LocalPlugin | undefined): string {
if (!plugin) {
return 'https://grafana.com/api/plugins/404notfound/versions/none/logos/small';
}
if (isLocalPlugin(plugin)) {
return plugin?.info?.logos?.large;
}
return `https://grafana.com/api/plugins/${plugin.slug}/versions/${plugin.version}/logos/small`;
}

@ -1,6 +1,6 @@
import React from 'react';
import { css } from '@emotion/css';
import { PluginTypeCode } from 'types';
import { PluginTypeCode } from '../types';
interface PluginTypeIconProps {
typeCode: PluginTypeCode;

@ -12,6 +12,10 @@ interface Props {
export const VersionList = ({ versions }: Props) => {
const styles = useStyles2(getStyles);
if (versions.length === 0) {
return <div className={styles.container}>No version history was found.</div>;
}
return (
<div className={styles.container}>
<table className={styles.table}>

@ -0,0 +1,2 @@
export const API_ROOT = '/api/plugins';
export const GRAFANA_API_ROOT = '/api/gnet';

@ -0,0 +1,6 @@
import { LocalPlugin } from './types';
export function isLocalPlugin(plugin: any): plugin is LocalPlugin {
// super naive way of figuring out if this is a local plugin
return 'category' in plugin;
}

@ -0,0 +1,111 @@
import { useEffect, useMemo, useState } from 'react';
import { Plugin, LocalPlugin } from '../types';
import { api } from '../api';
type PluginsState = {
isLoading: boolean;
items: Plugin[];
installedPlugins: any[];
};
export const usePlugins = () => {
const [state, setState] = useState<PluginsState>({ isLoading: true, items: [], installedPlugins: [] });
useEffect(() => {
const fetchPluginData = async () => {
const items = await api.getRemotePlugins();
const filteredPlugins = items.filter((plugin) => {
const isNotRenderer = plugin.typeCode !== 'renderer';
const isSigned = Boolean(plugin.versionSignatureType);
const isNotEnterprise = plugin.status !== 'enterprise';
return isNotRenderer && isSigned && isNotEnterprise;
});
const installedPlugins = await api.getInstalledPlugins();
setState((state) => ({ ...state, items: filteredPlugins, installedPlugins, isLoading: false }));
};
fetchPluginData();
}, []);
return state;
};
type FilteredPluginsState = {
isLoading: boolean;
items: Plugin[];
};
export const usePluginsByFilter = (searchBy: string, filterBy: string): FilteredPluginsState => {
const plugins = usePlugins();
const all = useMemo(() => {
const combined: Plugin[] = [];
Array.prototype.push.apply(combined, plugins.items);
Array.prototype.push.apply(combined, plugins.installedPlugins);
const bySlug = combined.reduce((unique: Record<string, Plugin>, plugin) => {
unique[plugin.slug] = plugin;
return unique;
}, {});
return Object.values(bySlug);
}, [plugins.items, plugins.installedPlugins]);
if (filterBy === 'installed') {
return {
isLoading: plugins.isLoading,
items: applySearchFilter(searchBy, plugins.installedPlugins ?? []),
};
}
return {
isLoading: plugins.isLoading,
items: applySearchFilter(searchBy, all),
};
};
function applySearchFilter(searchBy: string | undefined, plugins: Plugin[]): Plugin[] {
if (!searchBy) {
return plugins;
}
return plugins.filter((plugin) => {
const fields: String[] = [];
if (plugin.name) {
fields.push(plugin.name.toLowerCase());
}
if (plugin.orgName) {
fields.push(plugin.orgName.toLowerCase());
}
return fields.some((f) => f.includes(searchBy.toLowerCase()));
});
}
type PluginState = {
isLoading: boolean;
remote?: Plugin;
remoteVersions?: Array<{ version: string; createdAt: string }>;
local?: LocalPlugin;
};
export const usePlugin = (slug: string): PluginState => {
const [state, setState] = useState<PluginState>({
isLoading: true,
});
useEffect(() => {
const fetchPluginData = async () => {
const plugin = await api.getPlugin(slug);
setState({ ...plugin, isLoading: false });
};
fetchPluginData();
}, [slug]);
return state;
};

@ -0,0 +1,103 @@
import React, { ReactElement } from 'react';
import { css } from '@emotion/css';
import { SelectableValue, dateTimeParse } from '@grafana/data';
import { Field, LoadingPlaceholder, Select } from '@grafana/ui';
import { useLocation } from 'react-router-dom';
import { locationSearchToObject } from '@grafana/runtime';
import { PluginList } from '../components/PluginList';
import { SearchField } from '../components/SearchField';
import { HorizontalGroup } from '../components/HorizontalGroup';
import { useHistory } from '../hooks/useHistory';
import { Plugin } from '../types';
import { Page as PluginPage } from '../components/Page';
import { Page } from 'app/core/components/Page/Page';
import { usePluginsByFilter } from '../hooks/usePlugins';
import { useSelector } from 'react-redux';
import { StoreState } from 'app/types/store';
import { getNavModel } from 'app/core/selectors/navModel';
export default function Browse(): ReactElement {
const location = useLocation();
const query = locationSearchToObject(location.search);
const navModel = useSelector((state: StoreState) => getNavModel(state.navIndex, 'plugins'));
const q = query.q as string;
const filterBy = (query.filterBy as string) ?? 'installed';
const sortBy = (query.sortBy as string) ?? 'name';
const plugins = usePluginsByFilter(q, filterBy);
const sortedPlugins = plugins.items.sort(sorters[sortBy]);
const history = useHistory();
const onSortByChange = (value: SelectableValue<string>) => {
history.push({ query: { sortBy: value.value } });
};
const onFilterByChange = (value: SelectableValue<string>) => {
history.push({ query: { filterBy: value.value } });
};
const onSearch = (q: any) => {
history.push({ query: { filterBy: null, q } });
};
return (
<Page navModel={navModel}>
<Page.Contents>
<PluginPage>
<SearchField value={q} onSearch={onSearch} />
<HorizontalGroup>
<div>
{plugins.isLoading ? (
<LoadingPlaceholder
className={css`
margin-bottom: 0;
`}
text="Loading results"
/>
) : (
`${sortedPlugins.length} ${sortedPlugins.length > 1 ? 'results' : 'result'}`
)}
</div>
<Field label="Show">
<Select
width={15}
value={filterBy}
onChange={onFilterByChange}
options={[
{ value: 'all', label: 'All' },
{ value: 'installed', label: 'Installed' },
]}
/>
</Field>
<Field label="Sort by">
<Select
width={20}
value={sortBy}
onChange={onSortByChange}
options={[
{ value: 'name', label: 'Name' },
{ value: 'popularity', label: 'Popularity' },
{ value: 'updated', label: 'Updated date' },
{ value: 'published', label: 'Published date' },
{ value: 'downloads', label: 'Downloads' },
]}
/>
</Field>
</HorizontalGroup>
{!plugins.isLoading && <PluginList plugins={sortedPlugins} />}
</PluginPage>
</Page.Contents>
</Page>
);
}
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,
};

@ -0,0 +1,124 @@
import React from 'react';
import { cx, css } from '@emotion/css';
import { dateTimeParse, GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Legend, LinkButton } from '@grafana/ui';
import { locationService } from '@grafana/runtime';
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 { Plugin } from '../types';
import { Page as PluginPage } from '../components/Page';
import { Loader } from '../components/Loader';
import { Page } from 'app/core/components/Page/Page';
export default function Discover(): JSX.Element | null {
const { items, isLoading } = usePlugins();
const styles = useStyles2(getStyles);
const onSearch = (q: string) => {
locationService.push({
pathname: '/plugins/browse',
search: `?q=${q}`,
});
};
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 (
<Page>
<Page.Contents>
<Loader />
</Page.Contents>
</Page>
);
}
return (
<Page>
<Page.Contents>
<PluginPage>
<SearchField onSearch={onSearch} />
{/* Featured */}
<Legend className={styles.legend}>Featured</Legend>
<PluginList plugins={featuredPlugins.slice(0, 5)} />
{/* Most popular */}
<div className={styles.legendContainer}>
<Legend className={styles.legend}>Most popular</Legend>
<LinkButton href={'/plugins/browse?sortBy=popularity'}>See more</LinkButton>
</div>
<PluginList plugins={mostPopular.slice(0, 5)} />
{/* Recently added */}
<div className={styles.legendContainer}>
<Legend className={styles.legend}>Recently added</Legend>
<LinkButton href={'/plugins/browse?sortBy=published'}>See more</LinkButton>
</div>
<PluginList plugins={recentlyAdded.slice(0, 5)} />
{/* Browse by type */}
<Legend className={cx(styles.legend)}>Browse by type</Legend>
<Grid>
<Card
layout="horizontal"
href={'/plugins/browse?filterBy=panel'}
image={<PluginTypeIcon typeCode="panel" size={18} />}
text={<span className={styles.typeLegend}>&nbsp;Panels</span>}
/>
<Card
layout="horizontal"
href={'/plugins/browse?filterBy=datasource'}
image={<PluginTypeIcon typeCode="datasource" size={18} />}
text={<span className={styles.typeLegend}>&nbsp;Data sources</span>}
/>
<Card
layout="horizontal"
href={'/plugins/browse?filterBy=app'}
image={<PluginTypeIcon typeCode="app" size={18} />}
text={<span className={styles.typeLegend}>&nbsp;Apps</span>}
/>
</Grid>
</PluginPage>
</Page.Contents>
</Page>
);
}
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};
`,
};
};

@ -0,0 +1,60 @@
import React from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { PluginList } from '../components/PluginList';
import { usePlugins } from '../hooks/usePlugins';
import { Page as PluginPage } from '../components/Page';
import { Loader } from '../components/Loader';
import { CatalogTab, getCatalogNavModel } from './nav';
import { Page } from 'app/core/components/Page/Page';
export default function Library(): JSX.Element | null {
const { isLoading, items, installedPlugins } = usePlugins();
const styles = useStyles2(getStyles);
const filteredPlugins = items.filter((plugin) => !!installedPlugins.find((_) => _.id === plugin.slug));
if (isLoading) {
return (
<Page navModel={getCatalogNavModel(CatalogTab.Library, '/plugins')}>
<Page.Contents>
<Loader />
</Page.Contents>
</Page>
);
}
return (
<Page navModel={getCatalogNavModel(CatalogTab.Library, '/plugins')}>
<Page.Contents>
<PluginPage>
<h1 className={styles.header}>Library</h1>
{filteredPlugins.length > 0 ? (
<PluginList plugins={filteredPlugins} />
) : (
<p>
You haven&#39;t installed any plugins. Browse the{' '}
<a className={styles.link} href={'/plugins/browse?sortBy=popularity'}>
catalog
</a>{' '}
for plugins to install.
</p>
)}
</PluginPage>
</Page.Contents>
</Page>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
header: css`
margin-bottom: ${theme.spacing(3)};
margin-top: ${theme.spacing(3)};
`,
link: css`
text-decoration: underline;
`,
};
};

@ -0,0 +1,35 @@
import React from 'react';
import { css } from '@emotion/css';
import { NavModel, NavModelItem } from '@grafana/data';
import { Page as PluginPage } from '../components/Page';
import { Page } from 'app/core/components/Page/Page';
const node: NavModelItem = {
id: 'not-found',
text: 'The plugin catalog is not enabled',
icon: 'exclamation-triangle',
url: 'not-found',
};
const navModel: NavModel = { node, main: node };
export default function NotEnabled(): JSX.Element | null {
return (
<Page navModel={navModel}>
<Page.Contents>
<PluginPage>
To enable installing plugins via catalog, please refer to the{' '}
<a
className={css`
text-decoration: underline;
`}
href="https://grafana.com/docs/grafana/latest/plugins/catalog"
>
Plugin Catalog
</a>{' '}
instructions
</PluginPage>
</Page.Contents>
</Page>
);
}

@ -0,0 +1,167 @@
import React, { useState } from 'react';
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2, TabsBar, TabContent, Tab, Icon } from '@grafana/ui';
import { useParams } from 'react-router-dom';
import { VersionList } from '../components/VersionList';
import { InstallControls } from '../components/InstallControls';
import { usePlugin } from '../hooks/usePlugins';
import { Page as PluginPage } from '../components/Page';
import { Loader } from '../components/Loader';
import { Page } from 'app/core/components/Page/Page';
import { PluginLogo } from '../components/PluginLogo';
export default function PluginDetails(): JSX.Element | null {
const { pluginId } = useParams<{ pluginId: string }>();
const [tabs, setTabs] = useState([
{ label: 'Overview', active: true },
{ label: 'Version history', active: false },
]);
const { isLoading, local, remote, remoteVersions } = usePlugin(pluginId);
const styles = useStyles2(getStyles);
const description = remote?.description ?? local?.info?.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 (
<Page>
<Loader />
</Page>
);
}
return (
<Page>
<PluginPage>
<div className={styles.headerContainer}>
<PluginLogo
plugin={remote ?? local}
className={css`
object-fit: cover;
width: 100%;
height: 68px;
max-width: 68px;
`}
/>
<div className={styles.headerWrapper}>
<h1>{remote?.name ?? local?.name}</h1>
<div className={styles.headerLinks}>
<a className={styles.headerOrgName} href={'/plugins'}>
{remote?.orgName ?? local?.info?.author?.name}
</a>
{links.map((link: any) => (
<a key={link.name} href={link.url}>
{link.name}
</a>
))}
{downloads && (
<span>
<Icon name="cloud-download" />
{` ${new Intl.NumberFormat().format(downloads)}`}{' '}
</span>
)}
{version && <span>{version}</span>}
</div>
<p>{description}</p>
{remote && <InstallControls localPlugin={local} remotePlugin={remote} />}
</div>
</div>
<TabsBar>
{tabs.map((tab, key) => (
<Tab
key={key}
label={tab.label}
active={tab.active}
onChangeTab={() => {
setTabs(tabs.map((tab, index) => ({ ...tab, active: index === key })));
}}
/>
))}
</TabsBar>
<TabContent>
{tabs.find((_) => _.label === 'Overview')?.active && (
<div
className={styles.readme}
dangerouslySetInnerHTML={{ __html: readme ?? 'No plugin help or readme markdown file was found' }}
/>
)}
{tabs.find((_) => _.label === 'Version history')?.active && <VersionList versions={remoteVersions ?? []} />}
</TabContent>
</PluginPage>
</Page>
);
}
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;
}
}
`,
};
};

@ -16,17 +16,10 @@ export function getCatalogNavModel(tab: CatalogTab, baseURL: string): NavModel {
pages.push({
text: 'Browse',
icon: 'icon-gf icon-gf-apps',
url: `${baseURL}`,
url: `${baseURL}${CatalogTab.Browse}`,
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',

@ -42,15 +42,39 @@ export interface Plugin {
};
}
export type Metadata = {
export type LocalPlugin = {
category: string;
defaultNavUrl: string;
enabled: boolean;
hasUpdate: boolean;
id: string;
info: {
version: string;
author: {
name: string;
url: string;
};
build: {};
description: string;
links: Array<{
name: string;
url: string;
}>;
logos: {
large: string;
small: string;
};
updated: string;
version: string;
};
dev: boolean;
latestVersion: string;
name: string;
pinned: boolean;
signature: string;
signatureOrg: string;
signatureType: string;
state: string;
type: string;
dev: boolean | undefined;
};
export interface Version {
@ -61,7 +85,7 @@ export interface Version {
export interface PluginDetails {
remote?: Plugin;
remoteVersions?: Version[];
local?: Metadata;
local?: LocalPlugin;
}
export interface Org {

@ -0,0 +1,39 @@
import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynamicImport';
import { config } from 'app/core/config';
import { RouteDescriptor } from 'app/core/navigation/types';
export function getPluginsAdminRoutes(cfg = config): RouteDescriptor[] {
if (!cfg.pluginAdminEnabled) {
return [
{
path: '/plugins',
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './PluginListPage')),
},
{
path: '/plugins/browse',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "PluginAdminNotEnabled" */ './admin/pages/NotEnabed')
),
},
{
path: '/plugins/:pluginId/',
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './PluginPage')),
},
];
}
return [
{
path: '/plugins',
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './admin/pages/Browse')),
},
{
path: '/plugins/browse',
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginListPage" */ './admin/pages/Browse')),
},
{
path: '/plugins/:pluginId/',
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ './admin/pages/PluginDetails')),
},
];
}

@ -3,7 +3,6 @@ import { useAsync } from 'react-use';
import { css, cx } from '@emotion/css';
import { GrafanaTheme, PanelProps, PluginMeta, PluginType } from '@grafana/data';
import { CustomScrollbar, ModalsController, stylesFactory, Tooltip, useStyles } from '@grafana/ui';
import { config, locationService } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { UpdatePluginModal } from './components/UpdatePluginModal';
@ -49,16 +48,13 @@ export function PluginList(props: PanelProps) {
className={cx(styles.message, styles.messageUpdate)}
onClick={(e) => {
e.preventDefault();
if (config.pluginAdminEnabled) {
locationService.push(`/a/grafana-plugin-admin-app/plugin/${plugin.id}`);
} else {
showModal(UpdatePluginModal, {
pluginID: plugin.id,
pluginName: plugin.name,
onDismiss: hideModal,
isOpen: true,
});
}
showModal(UpdatePluginModal, {
pluginID: plugin.id,
pluginName: plugin.name,
onDismiss: hideModal,
isOpen: true,
});
}}
>
Update available!

@ -8,6 +8,7 @@ import { SafeDynamicImport } from '../core/components/DynamicImports/SafeDynamic
import { RouteDescriptor } from '../core/navigation/types';
import { Redirect } from 'react-router-dom';
import ErrorPage from 'app/core/components/ErrorPage/ErrorPage';
import { getPluginsAdminRoutes } from 'app/features/plugins/routes';
import { contextSrv } from 'app/core/services/context_srv';
export const extraRoutes: RouteDescriptor[] = [];
@ -322,16 +323,6 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "SnapshotListPage" */ 'app/features/manage-dashboards/SnapshotListPage')
),
},
{
path: '/plugins',
component: SafeDynamicImport(
() => import(/* webpackChunkName: "PluginListPage" */ 'app/features/plugins/PluginListPage')
),
},
{
path: '/plugins/:pluginId/',
component: SafeDynamicImport(() => import(/* webpackChunkName: "PluginPage" */ '../features/plugins/PluginPage')),
},
// TODO[Router]
// {
// path: '/plugins/:pluginId/page/:slug',
@ -517,12 +508,12 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "LibraryPanelsPage"*/ 'app/features/library-panels/LibraryPanelsPage')
),
},
...getPluginsAdminRoutes(),
...extraRoutes,
{
path: '/*',
component: ErrorPage,
},
// TODO[Router]
// ...playlistRoutes,
];

Loading…
Cancel
Save