mirror of https://github.com/grafana/grafana
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
parent
6266a9e77a
commit
52bd1eb1c5
@ -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"] |
||||
} |
||||
} |
||||
@ -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; |
||||
@ -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; |
||||
}; |
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
@ -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}> 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}; |
||||
`,
|
||||
}; |
||||
}; |
||||
@ -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; |
||||
`,
|
||||
}; |
||||
}; |
||||
@ -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; |
||||
} |
||||
} |
||||
`,
|
||||
}; |
||||
}; |
||||
@ -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')), |
||||
}, |
||||
]; |
||||
} |
||||
Loading…
Reference in new issue