mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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.
This commit is contained in:
@@ -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}> Panels</span>}
|
||||
/>
|
||||
<Card
|
||||
layout="horizontal"
|
||||
href={`${PLUGIN_ROOT}/browse?filterBy=datasource`}
|
||||
image={<PluginTypeIcon typeCode="datasource" size={18} />}
|
||||
text={<span className={styles.typeLegend}> Data sources</span>}
|
||||
/>
|
||||
<Card
|
||||
layout="horizontal"
|
||||
href={`${PLUGIN_ROOT}/browse?filterBy=app`}
|
||||
image={<PluginTypeIcon typeCode="app" size={18} />}
|
||||
text={<span className={styles.typeLegend}> 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'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};
|
||||
24
public/app/features/plugins/admin/components/PluginLogo.tsx
Normal file
24
public/app/features/plugins/admin/components/PluginLogo.tsx
Normal file
@@ -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}>
|
||||
2
public/app/features/plugins/admin/constants.ts
Normal file
2
public/app/features/plugins/admin/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const API_ROOT = '/api/plugins';
|
||||
export const GRAFANA_API_ROOT = '/api/gnet';
|
||||
6
public/app/features/plugins/admin/guards.ts
Normal file
6
public/app/features/plugins/admin/guards.ts
Normal file
@@ -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;
|
||||
}
|
||||
111
public/app/features/plugins/admin/hooks/usePlugins.tsx
Normal file
111
public/app/features/plugins/admin/hooks/usePlugins.tsx
Normal file
@@ -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;
|
||||
};
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
103
public/app/features/plugins/admin/pages/Browse.tsx
Normal file
103
public/app/features/plugins/admin/pages/Browse.tsx
Normal file
@@ -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,
|
||||
};
|
||||
124
public/app/features/plugins/admin/pages/Discover.tsx
Normal file
124
public/app/features/plugins/admin/pages/Discover.tsx
Normal file
@@ -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}> Panels</span>}
|
||||
/>
|
||||
<Card
|
||||
layout="horizontal"
|
||||
href={'/plugins/browse?filterBy=datasource'}
|
||||
image={<PluginTypeIcon typeCode="datasource" size={18} />}
|
||||
text={<span className={styles.typeLegend}> Data sources</span>}
|
||||
/>
|
||||
<Card
|
||||
layout="horizontal"
|
||||
href={'/plugins/browse?filterBy=app'}
|
||||
image={<PluginTypeIcon typeCode="app" size={18} />}
|
||||
text={<span className={styles.typeLegend}> 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};
|
||||
`,
|
||||
};
|
||||
};
|
||||
60
public/app/features/plugins/admin/pages/Library.tsx
Normal file
60
public/app/features/plugins/admin/pages/Library.tsx
Normal file
@@ -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'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;
|
||||
`,
|
||||
};
|
||||
};
|
||||
35
public/app/features/plugins/admin/pages/NotEnabed.tsx
Normal file
35
public/app/features/plugins/admin/pages/NotEnabed.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
167
public/app/features/plugins/admin/pages/PluginDetails.tsx
Normal file
167
public/app/features/plugins/admin/pages/PluginDetails.tsx
Normal file
@@ -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 {
|
||||
39
public/app/features/plugins/routes.ts
Normal file
39
public/app/features/plugins/routes.ts
Normal file
@@ -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,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user