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

* Allow Route component usage in app plugins

* i tried

* fix catalog app

* fix catalog app

* fix catalog app

* cleanup imports

* plugin catalog enabled to plugin admin

* rename plugin catalog to plugin admin

* expose catalog url

* update text

* import from react-router-dom

* fix imports -- add logging

* merge changes

* avoid onNavUpdate

* Fixed onNavChange issues

* fix library imports

* more links

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Ryan McKinley
2021-05-20 10:42:26 +02:00
committed by GitHub
co-authored by Dominik Prokop Torkel Ödegaard
parent 95ee5f01b5
commit a91edd7267
56 changed files with 316 additions and 247 deletions
@@ -0,0 +1,58 @@
import { AppRootProps } from '@grafana/data';
import React from 'react';
import { Discover } from 'pages/Discover';
import { Browse } from 'pages/Browse';
import { PluginDetails } from 'pages/PluginDetails';
import { Library } from 'pages/Library';
import { Route } from 'react-router-dom';
import { config } from '@grafana/runtime';
import { NotEnabled } from 'pages/NotEnabed';
export const CatalogRootPage = React.memo(function CatalogRootPage(props: AppRootProps) {
if (!config.pluginAdminEnabled) {
return <NotEnabled {...props} />;
}
return (
<>
<Route
exact
path={`${props.basename}`}
render={() => {
return <Browse {...props} />; // or discover?
}}
/>
<Route
exact
path={`${props.basename}/browse`}
render={() => {
return <Browse {...props} />;
}}
/>
<Route
exact
path={`${props.basename}/discover`}
render={() => {
return <Discover {...props} />;
}}
/>
<Route
path={`${props.basename}/plugin/:pluginId`}
render={() => {
return <PluginDetails {...props} />;
}}
/>
<Route
exact
path={`${props.basename}/library`}
render={() => {
return <Library {...props} />;
}}
/>
</>
);
});
@@ -0,0 +1,71 @@
import { getBackendSrv } from '@grafana/runtime';
import { PluginMeta } from '@grafana/data';
import { API_ROOT, GRAFANA_API_ROOT } from './constants';
import { Plugin, PluginDetails, Org } from './types';
async function getRemotePlugins(): Promise<Plugin[]> {
const res = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins`);
return res.items;
}
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;
});
return {
remote: res,
remoteVersions: versions,
local: plugin,
};
}
async function getPluginVersions(id: string): Promise<any[]> {
const versions = await getBackendSrv().get(`${GRAFANA_API_ROOT}/plugins/${id}/versions`);
return versions.items;
}
async function getInstalledPlugins(): Promise<any> {
const installed = await getBackendSrv().get(`${API_ROOT}?core=0`);
return installed;
}
async function getOrg(slug: string): Promise<Org> {
const org = await getBackendSrv().get(`${GRAFANA_API_ROOT}/orgs/${slug}`);
return { ...org, avatarUrl: `${GRAFANA_API_ROOT}/orgs/${slug}/avatar` };
}
async function installPlugin(id: string, version: string) {
return await getBackendSrv().post(`${API_ROOT}/${id}/install`, {
version,
});
}
async function uninstallPlugin(id: string) {
return await getBackendSrv().post(`${API_ROOT}/${id}/uninstall`);
}
async function updatePlugin(pluginId: string, data: Partial<PluginMeta>) {
const response = await getBackendSrv().datasourceRequest({
url: `/api/plugins/${pluginId}/settings`,
method: 'POST',
data,
});
return response?.data;
}
export const api = {
getRemotePlugins,
getPlugin,
getInstalledPlugins,
getOrg,
installPlugin,
uninstallPlugin,
updatePlugin,
};
@@ -0,0 +1,68 @@
import React from 'react';
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
interface Props {
href: string;
text: React.ReactNode;
image: React.ReactNode;
layout?: 'horizontal' | 'vertical';
}
export const Card = ({ href, text, image, layout = 'vertical' }: Props) => {
const styles = useStyles2(getCardStyles);
return (
<a href={href} className={styles.root}>
<div
className={cx(styles.container, {
[styles.containerHorizontal]: layout === 'horizontal',
})}
>
<div
className={cx(styles.imgContainer, {
[styles.imgContainerHorizontal]: layout === 'horizontal',
})}
>
{image}
</div>
{text}
</div>
</a>
);
};
const getCardStyles = (theme: GrafanaTheme2) => ({
root: css`
background-color: ${theme.colors.background.secondary};
border-radius: ${theme.shape.borderRadius()};
cursor: pointer;
height: 100%;
padding: ${theme.spacing(2)};
&:hover {
background-color: ${theme.colors.action.hover};
}
`,
container: css`
display: flex;
flex-direction: column;
justify-content: space-around;
height: 100%;
`,
containerHorizontal: css`
flex-direction: row;
justify-content: flex-start;
`,
imgContainer: css`
align-items: center;
display: flex;
flex-grow: 1;
justify-content: center;
padding: ${theme.spacing()} 0;
`,
imgContainerHorizontal: css`
flex-grow: 0;
`,
});
@@ -0,0 +1,23 @@
import React from 'react';
import { css } from '@emotion/css';
import { useTheme2 } from '@grafana/ui';
interface Props {
children: React.ReactNode;
}
export const Grid = ({ children }: Props) => {
const theme = useTheme2();
return (
<div
className={css`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(175px, 1fr));
grid-gap: ${theme.spacing.gridSize}px;
`}
>
{children}
</div>
);
};
@@ -0,0 +1,32 @@
import React from 'react';
import { css } from '@emotion/css';
import { useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
interface HorizontalGroupProps {
children: React.ReactNode;
}
export const HorizontalGroup = ({ children }: HorizontalGroupProps) => {
const styles = useStyles2(getStyles);
return <div className={styles.container}>{children}</div>;
};
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
display: flex;
align-items: center;
justify-content: space-between;
& > * {
margin-right: ${theme.spacing()};
}
& > *:first-child {
flex-grow: 1;
}
& > *:last-child {
margin-right: 0;
}
`,
});
@@ -0,0 +1,155 @@
import React, { useState } from 'react';
import { css } from '@emotion/css';
import { gt, satisfies } from 'semver';
import { config } from '@grafana/runtime';
import { Button, HorizontalGroup, Icon, useStyles2 } from '@grafana/ui';
import { AppEvents, GrafanaTheme2, OrgRole } from '@grafana/data';
import { Metadata, Plugin } from '../types';
import { hasRole } from '../helpers';
import { api } from '../api';
// This isn't exported in the sdk yet
// @ts-ignore
import appEvents from 'grafana/app/core/app_events';
interface Props {
localPlugin?: Metadata;
remotePlugin: Plugin;
}
export const InstallControls = ({ localPlugin, remotePlugin }: Props) => {
const [loading, setLoading] = useState(false);
const [isInstalled, setIsInstalled] = useState(Boolean(localPlugin));
const [shouldUpdate, setShouldUpdate] = useState(
remotePlugin?.version && localPlugin?.info.version && gt(remotePlugin?.version!, localPlugin?.info.version!)
);
const styles = useStyles2(getStyles);
const onInstall = async () => {
setLoading(true);
try {
await api.installPlugin(remotePlugin.slug, remotePlugin.version);
appEvents.emit(AppEvents.alertSuccess, [`Installed ${remotePlugin?.name}`]);
setLoading(false);
setIsInstalled(true);
} catch (error) {
setLoading(false);
}
};
const onUninstall = async () => {
setLoading(true);
try {
await api.uninstallPlugin(remotePlugin.slug);
appEvents.emit(AppEvents.alertSuccess, [`Uninstalled ${remotePlugin?.name}`]);
setLoading(false);
setIsInstalled(false);
} catch (error) {
setLoading(false);
}
};
const onUpdate = async () => {
setLoading(true);
try {
await api.installPlugin(remotePlugin.slug, remotePlugin.version);
appEvents.emit(AppEvents.alertSuccess, [`Updated ${remotePlugin?.name}`]);
setLoading(false);
setShouldUpdate(false);
} catch (error) {
setLoading(false);
}
};
const grafanaDependency = remotePlugin?.json?.dependencies?.grafanaDependency;
const unsupportedGrafanaVersion = grafanaDependency
? !satisfies(config.buildInfo.version, grafanaDependency, {
// needed for when running against master
includePrerelease: true,
})
: false;
const isDevelopmentBuild = Boolean(localPlugin?.dev);
const isEnterprise = remotePlugin?.status === 'enterprise';
const hasPermission = hasRole(OrgRole.Admin);
if (isEnterprise) {
return (
<div className={styles.message}>
Marketplace doesn&#39;t support installing Enterprise plugins yet. Stay tuned!
</div>
);
}
if (isDevelopmentBuild) {
return (
<div className={styles.message}>This is a development build of the plugin and can&#39;t be uninstalled.</div>
);
}
if (isInstalled) {
return (
<HorizontalGroup height="auto">
{shouldUpdate && (
<Button disabled={loading || !hasPermission} onClick={onUpdate}>
{loading ? 'Updating' : 'Update'}
</Button>
)}
<Button variant="destructive" disabled={loading || !hasPermission} onClick={onUninstall}>
{loading && !shouldUpdate ? 'Uninstalling' : 'Uninstall'}
</Button>
{!hasPermission && <div className={styles.message}>You need admin privileges to manage this plugin.</div>}
</HorizontalGroup>
);
}
if (unsupportedGrafanaVersion) {
return (
<div className={styles.message}>
<Icon name="exclamation-triangle" />
&nbsp;This plugin doesn&#39;t support your version of Grafana.
</div>
);
}
return (
<HorizontalGroup height="auto">
<Button disabled={loading || !hasPermission} onClick={onInstall}>
{loading ? 'Installing' : 'Install'}
</Button>
{!hasPermission && <div className={styles.message}>You need admin privileges to install this plugin.</div>}
</HorizontalGroup>
);
};
export const getStyles = (theme: GrafanaTheme2) => {
return {
message: css`
color: ${theme.colors.text.secondary};
`,
readme: css`
margin: ${theme.spacing(3)} 0;
& img {
max-width: 100%;
}
h1,
h2,
h3 {
margin-top: ${theme.spacing(3)};
margin-bottom: ${theme.spacing(2)};
}
li {
margin-left: ${theme.spacing(2)};
& > p {
margin: ${theme.spacing()} 0;
}
}
`,
};
};
@@ -0,0 +1,13 @@
import React from 'react';
import { LoadingPlaceholder } from '@grafana/ui';
import { Page } from './Page';
export const Loader = () => {
return (
<Page>
<div className="page-loader-wrapper">
<LoadingPlaceholder text="Loading..." />
</div>
</Page>
);
};
@@ -0,0 +1,18 @@
import React from 'react';
import { css } from '@emotion/css';
import { useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
export const Page: React.FC = ({ children }) => {
const styles = useStyles2(getStyles);
return (
<div className="page-container">
<div className={styles}>{children}</div>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) =>
css`
margin-bottom: ${theme.spacing(3)};
`;
@@ -0,0 +1,65 @@
import React from 'react';
import { useStyles2 } from '@grafana/ui';
import { css } from '@emotion/css';
import { Card } from '../components/Card';
import { Grid } from '../components/Grid';
import { PLUGIN_ROOT } from '../constants';
import { Plugin } from '../types';
import { GrafanaTheme2 } from '@grafana/data';
interface Props {
plugins: Plugin[];
}
export const PluginList = ({ plugins }: Props) => {
const styles = useStyles2(getStyles);
return (
<Grid>
{plugins.map((plugin) => {
const { name, slug, version, orgName, typeCode } = plugin;
return (
<Card
key={`${orgName}-${name}-${typeCode}`}
href={`${PLUGIN_ROOT}/plugin/${slug}`}
image={
<img
src={`https://grafana.com/api/plugins/${slug}/versions/${version}/logos/small`}
className={css`
max-height: 64px;
`}
/>
}
text={
<>
<div className={styles.name}>{name}</div>
<div className={styles.orgName}>{orgName}</div>
</>
}
/>
);
})}
</Grid>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
name: css`
font-size: ${theme.typography.h4.fontSize};
color: ${theme.colors.text};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
`,
orgName: css`
font-size: ${theme.typography.body.fontSize};
color: ${theme.colors.text.secondary};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
`,
});
@@ -0,0 +1,36 @@
import React from 'react';
import { css } from '@emotion/css';
import { PluginTypeCode } from 'types';
interface PluginTypeIconProps {
typeCode: PluginTypeCode;
size: number;
}
export const PluginTypeIcon = ({ typeCode, size }: PluginTypeIconProps) => {
const imageUrl = ((typeCode: string) => {
switch (typeCode) {
case 'panel':
return 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNSIgaGVpZ2h0PSIyNC4zMzEiIHZpZXdCb3g9Ii02MiA2My42NjkgMjUgMjQuMzMxIj48dGl0bGU+aWNvbl9kYXRhLXNvdXJjZTwvdGl0bGU+PHBhdGggZD0iTS00MS40MDUgNjMuNjczaC0xNi4xOUE0LjQxIDQuNDEgMCAwIDAtNjIgNjguMDc4djE1LjUxN0E0LjQxIDQuNDEgMCAwIDAtNTcuNTk1IDg4aDE2LjE5QTQuNDEgNC40MSAwIDAgMC0zNyA4My41OTVWNjguMDc4YTQuNDEgNC40MSAwIDAgMC00LjQwNS00LjQwNXptMy43MjcgMTkuOTIyYTMuNzMxIDMuNzMxIDAgMCAxLTMuNzI3IDMuNzI3aC0xNi4xOWEzLjczMSAzLjczMSAwIDAgMS0zLjcyNy0zLjcyN1Y2OC4wNzhhMy43MzEgMy43MzEgMCAwIDEgMy43MjctMy43MjdoMTYuMTlhMy43MzEgMy43MzEgMCAwIDEgMy43MjcgMy43Mjd2MTUuNTE3eiIgZmlsbD0iIzg5ODk4OSIvPjxnIGZpbGw9IiM4OTg5ODkiPjxwYXRoIGQ9Ik0tNTYuNDU3IDg1LjE0N2gxMy45MTRhMi4zNSAyLjM1IDAgMCAwIDIuMjctMS43NTloLTE4LjQ1NGEyLjM1MSAyLjM1MSAwIDAgMCAyLjI3IDEuNzU5em0uMDQ3LTguNzA2bDIuMDg3LjgzLjgxLS45NzdoLTUuMjk5djEuNjgzbDEuNjM2LTEuNDA4YS43NTEuNzUxIDAgMCAxIC43NjYtLjEyOHptNS44MzktMy42OTRoLTguMjQxdjIuODI4aDUuODk1em03Ljk0OSAyLjgyOGguNzM5bDEuNjk1LTEuMzA0di0xLjUyNGgtNC4yN3ptLTE2LjE5IDQuMzgxdjIuNzEzaDE4LjYyNHYtMi44MjhoLTE4LjQ5MXptOS43NjYtOS4wNDdhLjc0OC43NDggMCAwIDEgLjg5MS0uMjAybDIuODY5IDEuMzIyaDUuMDk5VjY5LjJoLTE4LjYyNXYyLjgyOGg4LjgzOGwuOTI4LTEuMTE5em02LjUwMy00LjM4N2gtMTMuOTE0YTIuMzUyIDIuMzUyIDAgMCAwLTIuMzE2IDEuOTZoMTguNTQ1YTIuMzUgMi4zNSAwIDAgMC0yLjMxNS0xLjk2em0tNC43NjggNi4yMjVoLTEuMzExbC0yLjM0NiAyLjgyOGg2LjU1OGwtMS4zODItMi4xMjh6Ii8+PHBhdGggZD0iTS00Mi4xMDUgNzcuNjM5YS43NDcuNzQ3IDAgMCAxLTEuMDg2LS4xODZsLS43NTItMS4xNThoLTcuNjIxbC0xLjk1MiAyLjM1NGEuNzUuNzUgMCAwIDEtLjg1NC4yMTlsLTIuMTcyLS44NjQtMS4zMDEgMS4xMmgxNy42NTd2LTIuODI4aC0uMTdsLTEuNzQ5IDEuMzQzeiIvPjwvZz48L3N2Zz4=';
case 'datasource':
return 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxOS4wLjEsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMjVweCIgaGVpZ2h0PSIyNC4zcHgiIHZpZXdCb3g9Ii0xODcgNzMuNyAyNSAyNC4zIiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IC0xODcgNzMuNyAyNSAyNC4zOyIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSINCgk+DQo8c3R5bGUgdHlwZT0idGV4dC9jc3MiPg0KCS5zdDB7ZmlsbDojNWE1YTVhO30NCjwvc3R5bGU+DQo8Zz4NCgk8dGl0bGU+aWNvbl9kYXRhLXNvdXJjZTwvdGl0bGU+DQoJPGc+DQoJCTxwYXRoIGNsYXNzPSJzdDAiIGQ9Ik0tMTc0LjUsOTQuM2MtNS41LDAtMTAuMi0xLjYtMTIuMy00Yy0wLjEsMC4zLTAuMiwwLjYtMC4yLDFjMCwzLjIsNS43LDYsMTIuNSw2czEyLjUtMi43LDEyLjUtNg0KCQkJYzAtMC4zLTAuMS0wLjctMC4yLTFDLTE2NC40LDkyLjctMTY5LDk0LjMtMTc0LjUsOTQuM3oiLz4NCgkJPHBhdGggY2xhc3M9InN0MCIgZD0iTS0xNzQuNSw4OC45Yy01LjUsMC0xMC4yLTEuNi0xMi4zLTRjLTAuMSwwLjMtMC4yLDAuNi0wLjIsMWMwLDMuMiw1LjcsNiwxMi41LDZzMTIuNS0yLjcsMTIuNS02DQoJCQljMC0wLjMtMC4xLTAuNy0wLjItMUMtMTY0LjQsODcuMy0xNjksODguOS0xNzQuNSw4OC45eiIvPg0KCQk8cGF0aCBjbGFzcz0ic3QwIiBkPSJNLTE4Nyw4MC40YzAsMy4yLDUuNyw2LDEyLjUsNnMxMi41LTIuNywxMi41LTZzLTUuNy02LTEyLjUtNlMtMTg3LDc3LjEtMTg3LDgwLjR6Ii8+DQoJPC9nPg0KPC9nPg0KPC9zdmc+DQo=';
case 'app':
return 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMi45NzgiIGhlaWdodD0iMjUiIHZpZXdCb3g9IjAgMCAyMi45NzggMjUiPjx0aXRsZT5pY29uX2FwcHM8L3RpdGxlPjxwYXRoIGQ9Ik0yMS4yMjQgMTEuMjFhMS43NiAxLjc2IDAgMCAwLTEuNjgyIDEuMjU3SDE0Ljg5YTQuMjMgNC4yMyAwIDAgMC0uMy0xLjE0MmwzLjExNC0xLjhBMi4wNSAyLjA1IDAgMSAwIDE3LjEyIDguMWExLjk4NiAxLjk4NiAwIDAgMCAuMDguNTY1bC0zLjExOCAxLjhhNC4yNDMgNC4yNDMgMCAwIDAtLjgzNS0uODM1bC41ODYtMS4wMTVhMi4xNjUgMi4xNjUgMCAwIDAgLjU5My4wODYgMi4xMTYgMi4xMTYgMCAxIDAtMS40NS0uNThsLS41OCAxLjAxMmEzLjk1NSAzLjk1NSAwIDAgMC0xLjE0LS4zVjMuMDA4YTEuNTQ3IDEuNTQ3IDAgMSAwLTEgMHY1LjgxN2E0LjIzIDQuMjMgMCAwIDAtMS4xNDMuM2wtMi4wNi0zLjU2MkExLjY4NCAxLjY4NCAwIDAgMCA3LjUxIDQuNGExLjcxIDEuNzEgMCAxIDAtMS4zMiAxLjY2bDIuMDYgMy41N2E0LjMyMyA0LjMyMyAwIDAgMC0uODQzLjg0M2wtMy41NjYtMi4wNmExLjc2IDEuNzYgMCAwIDAgLjA0NS0uMzkgMS43IDEuNyAwIDEgMC0xLjcgMS43IDEuNjg2IDEuNjg2IDAgMCAwIDEuMTU1LS40NTNsMy41NyAyLjA2YTQuMDkgNC4wOSAwIDAgMC0uMyAxLjEzM0g1LjIwNmEyLjMwNSAyLjMwNSAwIDEgMCAwIDFoMS40MDdhNC4yMyA0LjIzIDAgMCAwIC4zIDEuMTQyTDMuMTAyIDE2LjhhMS44MjMgMS44MjMgMCAxIDAgLjU1IDEuMyAxLjc3NSAxLjc3NSAwIDAgMC0uMDYzLS40MzhsMy44MjItMi4yMDZhNC4yIDQuMiAwIDAgMCAuODQzLjg0bC0yLjk4IDUuMTkzYTEuNzI3IDEuNzI3IDAgMCAwLS40MTMtLjA1IDEuNzggMS43OCAwIDEgMCAxLjI3Ny41NGwyLjk4LTUuMTc4YTQuMDkgNC4wOSAwIDAgMCAxLjEzMy4zdjEuNDA4YTIuMDU1IDIuMDU1IDAgMSAwIC45OSAwVjE3LjFhNC4yMyA0LjIzIDAgMCAwIDEuMTQzLS4zbDIuNDYgNC4yNmExLjgyNCAxLjgyNCAwIDEgMCAxLjMwNi0uNTUyIDEuNzc4IDEuNzc4IDAgMCAwLS40NDYuMDU3bC0yLjQ2LTQuMjY1YTMuOTYgMy45NiAwIDAgMCAuODI2LS44MjdsLjQ0Ni4yNThhMi4zMjQgMi4zMjQgMCAwIDAtLjEyLjczOCAyLjQgMi40IDAgMSAwIC42Mi0xLjZsLS40NDMtLjI1NGE0LjE1NSA0LjE1NSAwIDAgMCAuMzEtMS4xNTRoNC42NmExLjc1MyAxLjc1MyAwIDEgMCAxLjY4LTIuMjV6bTAgMi43MWEuOTU4Ljk1OCAwIDEgMSAuOTU4LS45NTguOTYuOTYgMCAwIDEtLjk1OC45NnpNMTAuNzUgMTYuMTRhMy4xNzcgMy4xNzcgMCAxIDEgMy4xNzctMy4xNzggMy4xOCAzLjE4IDAgMCAxLTMuMTc3IDMuMTc3em03LjE2My04LjA0YTEuMjYgMS4yNiAwIDEgMSAxLjI2IDEuMjYgMS4yNiAxLjI2IDAgMCAxLTEuMjYtMS4yNnpNMTUuNzQgNi41OTRhMS4zMTQgMS4zMTQgMCAxIDEtMS4zMTUtMS4zMTQgMS4zMTUgMS4zMTUgMCAwIDEgMS4zMTQgMS4zMTR6TTkuOTk2IDEuNTQ4YS43NTMuNzUzIDAgMSAxIC43NTMuNzUzLjc1NC43NTQgMCAwIDEtLjc1My0uNzUyek00Ljg5NyA0LjRhLjkxLjkxIDAgMSAxIC45MS45MS45MS45MSAwIDAgMS0uOTEtLjkxek0yLjE5IDguOTM2YS45MS45MSAwIDEgMSAuOTA4LS45MS45MS45MSAwIDAgMS0uOTEuOTF6bS43NyA1LjUyNmExLjUwNiAxLjUwNiAwIDEgMSAxLjUwNS0xLjUwNiAxLjUwOCAxLjUwOCAwIDAgMS0xLjUwOCAxLjUwNnptLS4xIDMuNjQ2YTEuMDMyIDEuMDMyIDAgMSAxLTEuMDMzLTEuMDMyIDEuMDMzIDEuMDMzIDAgMCAxIDEuMDMyIDEuMDMyem0yLjk4NyA1LjExYS45ODYuOTg2IDAgMSAxLS45ODYtLjk4Ny45ODcuOTg3IDAgMCAxIC45ODcuOTg3em00LjktMS40NmExLjI2IDEuMjYgMCAxIDEgMS4yNi0xLjI2IDEuMjYgMS4yNiAwIDAgMS0xLjI1NyAxLjI2em02LjQ0Mi41N2ExLjAzMiAxLjAzMiAwIDEgMS0xLjAzMy0xLjAyOCAxLjAzMyAxLjAzMyAwIDAgMSAxLjAzMiAxLjAyOHptLS40LTcuNDU4YTEuNiAxLjYgMCAxIDEtMS42IDEuNiAxLjYgMS42IDAgMCAxIDEuNi0xLjZ6IiBmaWxsPSIjODk4OTg5Ii8+PC9zdmc+';
default:
return undefined;
}
})(typeCode);
return imageUrl ? (
<div
className={css`
display: inline-block;
background-image: url(${imageUrl});
background-size: ${size}px;
background-repeat: no-repeat;
width: ${size}px;
height: ${size}px;
`}
/>
) : null;
};
@@ -0,0 +1,46 @@
import React, { useState } from 'react';
import { css } from '@emotion/css';
import { useStyles2 } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
interface Props {
value?: string;
onSearch: (value: string) => void;
}
export const SearchField = ({ value, onSearch }: Props) => {
const [query, setQuery] = useState(value);
const styles = useStyles2(getStyles);
return (
<input
value={query}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.keyCode === 13) {
onSearch(e.currentTarget.value);
}
}}
className={styles}
placeholder="Search Grafana plugins"
onChange={(e) => {
setQuery(e.currentTarget.value);
}}
/>
);
};
const getStyles = (theme: GrafanaTheme2) => css`
outline: none;
font-size: 20px;
width: 100%;
border-bottom: 2px solid ${theme.colors.border.weak};
background: transparent;
line-height: 38px;
font-weight: 400;
padding: ${theme.spacing(0.5)};
margin: ${theme.spacing(3)} 0;
&::placeholder {
color: ${theme.colors.action.disabledText};
}
`;
@@ -0,0 +1,53 @@
import React from 'react';
import { css } from '@emotion/css';
import { dateTimeFormatTimeAgo, GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { Version } from '../types';
interface Props {
versions: Version[];
}
export const VersionList = ({ versions }: Props) => {
const styles = useStyles2(getStyles);
return (
<div className={styles.container}>
<table className={styles.table}>
<thead>
<tr>
<th>Version</th>
<th>Last updated</th>
</tr>
</thead>
<tbody>
{versions.map((version) => {
return (
<tr key={version.version}>
<td>{version.version}</td>
<td>{dateTimeFormatTimeAgo(version.createdAt)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
container: css`
padding: ${theme.spacing(2, 4, 3)};
`,
table: css`
width: 100%;
td,
th {
padding: ${theme.spacing()} 0;
}
th {
font-size: ${theme.typography.h5.fontSize};
}
`,
});
@@ -0,0 +1,19 @@
import React from 'react';
import { PluginConfigPageProps, AppPluginMeta } from '@grafana/data';
import { LinkButton } from '@grafana/ui';
import { PLUGIN_ROOT } from '../constants';
import { config } from '@grafana/runtime';
interface Props extends PluginConfigPageProps<AppPluginMeta> {}
export const Settings = ({ plugin }: Props) => {
if (!config.pluginAdminEnabled) {
return <div>Plugin admin is not enabled.</div>;
}
return (
<>
<LinkButton href={PLUGIN_ROOT}>Manage plugins</LinkButton>
</>
);
};
@@ -0,0 +1,4 @@
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';
@@ -0,0 +1,20 @@
import { OrgRole } from '@grafana/data';
import { config } from '@grafana/runtime';
export function hasRole(requiredRole: OrgRole): boolean {
const user = config.bootData.user;
switch (requiredRole) {
case OrgRole.Admin: {
return user.orgRole === OrgRole.Admin;
}
case OrgRole.Editor: {
return user.orgRole === OrgRole.Admin || user.orgRole === OrgRole.Editor;
}
case OrgRole.Viewer: {
return user.orgRole === OrgRole.Admin || user.orgRole === OrgRole.Editor || user.orgRole === OrgRole.Viewer;
}
default: {
return false;
}
}
}
@@ -0,0 +1,13 @@
import { getLocationSrv } from '@grafana/runtime';
export const useHistory = () => {
return {
push: ({ query }: any) => {
getLocationSrv().update({
partial: true,
replace: false,
query,
});
},
};
};
@@ -0,0 +1,58 @@
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;
};
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.799368,0,0,0.799368,2.40829,2.40734)">
<path d="M22,7.82C22.005,7.757 22.005,7.693 22,7.63L20,2.63C19.84,2.226 19.434,1.97 19,2L5,2C4.591,2 4.221,2.25 4.07,2.63L2.07,7.63C2.065,7.693 2.065,7.757 2.07,7.82C2.037,7.876 2.013,7.937 2,8C2.023,9.404 2.783,10.698 4,11.4L4,21C4,21.549 4.451,22 5,22L19,22C19.549,22 20,21.549 20,21L20,11.44C21.229,10.731 21.991,9.419 22,8C22.009,7.94 22.009,7.88 22,7.82ZM13,20L11,20L11,16L13,16L13,20ZM18,20L15,20L15,15C15,14.451 14.549,14 14,14L10,14C9.451,14 9,14.451 9,15L9,20L6,20L6,12C7.151,11.994 8.246,11.49 9,10.62C9.759,11.481 10.853,11.974 12,11.974C13.147,11.974 14.241,11.481 15,10.62C15.754,11.49 16.849,11.994 18,12L18,20ZM18,10C16.903,10 16,9.097 16,8C16,7.451 15.549,7 15,7C14.451,7 14,7.451 14,8C14,9.097 13.097,10 12,10C10.903,10 10,9.097 10,8C10,7.451 9.549,7 9,7C8.451,7 8,7.451 8,8C8.001,8.025 8.001,8.05 8.001,8.075C8.001,9.173 7.098,10.076 6,10.076C4.93,10.076 4.04,9.219 4,8.15L5.68,4L18.32,4L20,8.15C19.922,9.19 19.043,10.003 18,10Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
</g>
<defs>
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(20.007,0,0,20.0024,2,11.9988)"><stop offset="0" style="stop-color:rgb(242,204,12);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(255,152,48);stop-opacity:1"/></linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,10 @@
import { AppPlugin } from '@grafana/data';
import { Settings } from './config/Settings';
import { CatalogRootPage } from './RootPage';
export const plugin = new AppPlugin().setRootPage(CatalogRootPage as any).addConfigPage({
title: 'Settings',
icon: 'info-circle',
body: Settings as any,
id: 'settings',
});
@@ -0,0 +1,111 @@
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,
};
@@ -0,0 +1,114 @@
import React from 'react';
import { cx, css } from '@emotion/css';
import { dateTimeParse, AppRootProps, GrafanaTheme2 } from '@grafana/data';
import { useStyles2, Legend, LinkButton } from '@grafana/ui';
import { locationService } from '@grafana/runtime';
import { PLUGIN_ROOT } from '../constants';
import { Card } from '../components/Card';
import { Grid } from '../components/Grid';
import { PluginList } from '../components/PluginList';
import { SearchField } from '../components/SearchField';
import { PluginTypeIcon } from '../components/PluginTypeIcon';
import { usePlugins } from '../hooks/usePlugins';
import { Plugin } from '../types';
import { Page } from 'components/Page';
import { Loader } from 'components/Loader';
export const Discover = ({ meta }: AppRootProps) => {
const { items, isLoading } = usePlugins();
const styles = useStyles2(getStyles);
const onSearch = (q: string) => {
locationService.push({
pathname: `${PLUGIN_ROOT}/browse`,
search: `?q=${q}`,
});
};
const featuredPlugins = items.filter((_) => _.featured > 0);
featuredPlugins.sort((a: Plugin, b: Plugin) => {
return b.featured - a.featured;
});
const recentlyAdded = items.filter((_) => true);
recentlyAdded.sort((a: Plugin, b: Plugin) => {
const at = dateTimeParse(a.createdAt);
const bt = dateTimeParse(b.createdAt);
return bt.valueOf() - at.valueOf();
});
const mostPopular = items.filter((_) => true);
mostPopular.sort((a: Plugin, b: Plugin) => {
return b.popularity - a.popularity;
});
if (isLoading) {
return <Loader />;
}
return (
<Page>
<SearchField onSearch={onSearch} />
{/* Featured */}
<Legend className={styles.legend}>Featured</Legend>
<PluginList plugins={featuredPlugins.slice(0, 5)} />
{/* Most popular */}
<div className={styles.legendContainer}>
<Legend className={styles.legend}>Most popular</Legend>
<LinkButton href={`${PLUGIN_ROOT}/browse?sortBy=popularity`}>See more</LinkButton>
</div>
<PluginList plugins={mostPopular.slice(0, 5)} />
{/* Recently added */}
<div className={styles.legendContainer}>
<Legend className={styles.legend}>Recently added</Legend>
<LinkButton href={`${PLUGIN_ROOT}/browse?sortBy=published'`}>See more</LinkButton>
</div>
<PluginList plugins={recentlyAdded.slice(0, 5)} />
{/* Browse by type */}
<Legend className={cx(styles.legend)}>Browse by type</Legend>
<Grid>
<Card
layout="horizontal"
href={`${PLUGIN_ROOT}/browse?filterBy=panel`}
image={<PluginTypeIcon typeCode="panel" size={18} />}
text={<span className={styles.typeLegend}>&nbsp;Panels</span>}
/>
<Card
layout="horizontal"
href={`${PLUGIN_ROOT}/browse?filterBy=datasource`}
image={<PluginTypeIcon typeCode="datasource" size={18} />}
text={<span className={styles.typeLegend}>&nbsp;Data sources</span>}
/>
<Card
layout="horizontal"
href={`${PLUGIN_ROOT}/browse?filterBy=app`}
image={<PluginTypeIcon typeCode="app" size={18} />}
text={<span className={styles.typeLegend}>&nbsp;Apps</span>}
/>
</Grid>
</Page>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
legend: css`
margin-top: ${theme.spacing(4)};
`,
legendContainer: css`
align-items: baseline;
display: flex;
justify-content: space-between;
`,
typeLegend: css`
align-items: center;
display: flex;
font-size: ${theme.typography.h4.fontSize};
`,
};
};
@@ -0,0 +1,54 @@
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.Browse, basename));
}, [onNavChanged, basename]);
const filteredPlugins = items.filter((plugin) => !!installedPlugins.find((_) => _.id === plugin.slug));
if (isLoading) {
return <Loader />;
}
return (
<Page>
<h1 className={styles.header}>Library</h1>
{filteredPlugins.length > 0 ? (
<PluginList plugins={filteredPlugins} />
) : (
<p>
You haven&#39;t installed any plugins. Browse the{' '}
<a className={styles.link} href={`${PLUGIN_ROOT}/browse?sortBy=popularity`}>
catalog
</a>{' '}
for plugins to install.
</p>
)}
</Page>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
header: css`
margin-bottom: ${theme.spacing(3)};
margin-top: ${theme.spacing(3)};
`,
link: css`
text-decoration: underline;
`,
};
};
@@ -0,0 +1,23 @@
import React from 'react';
import { Page } from 'components/Page';
import { AppRootProps, NavModelItem } from '@grafana/data';
export const NotEnabled = ({ onNavChanged }: AppRootProps) => {
const node: NavModelItem = {
id: 'not-found',
text: 'The plugin catalog is not enabled',
icon: 'exclamation-triangle',
url: 'not-found',
};
onNavChanged({
node: node,
main: node,
});
return (
<Page>
To enabled installing plugins, set the{' '}
<a href="https://grafana.com/docs/grafana/latest/plugins/catalog">Plugin Catalog</a> instructions
</Page>
);
};
@@ -0,0 +1,160 @@
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;
}
}
`,
};
};
@@ -0,0 +1,40 @@
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',
},
];
@@ -0,0 +1,67 @@
import { NavModel, NavModelItem } from '@grafana/data';
export enum CatalogTab {
Browse = 'browse',
Discover = 'discover',
Library = 'library',
}
export function getCatalogNavModel(tab: CatalogTab, baseURL: string): NavModel {
const pages: NavModelItem[] = [];
if (!baseURL.endsWith('/')) {
baseURL += '/';
}
pages.push({
text: 'Browse',
icon: 'icon-gf icon-gf-apps',
url: `${baseURL}`,
id: CatalogTab.Browse,
});
// pages.push({
// text: 'Discover',
// icon: 'file-alt',
// url: `${baseURL}${CatalogTab.Discover}`,
// id: CatalogTab.Discover,
// });
pages.push({
text: 'Library',
icon: 'icon-gf icon-gf-apps',
url: `${baseURL}${CatalogTab.Library}`,
id: CatalogTab.Library,
});
const node: NavModelItem = {
text: 'Catalog',
icon: 'cog',
subTitle: 'Manage plugin installations',
breadcrumbs: [{ title: 'Plugins', url: 'plugins' }],
children: setActivePage(tab, pages, CatalogTab.Browse),
};
return {
node: node,
main: node,
};
}
function setActivePage(pageId: CatalogTab, pages: NavModelItem[], defaultPageId: CatalogTab): NavModelItem[] {
let found = false;
const selected = pageId || defaultPageId;
const changed = pages.map((p) => {
const active = !found && selected === p.id;
if (active) {
found = true;
}
return { ...p, active };
});
if (!found) {
changed[0].active = true;
}
return changed;
}
@@ -0,0 +1,29 @@
{
"$schema": "https://github.com/grafana/grafana/raw/master/docs/sources/developers/plugins/plugin.schema.json",
"type": "app",
"name": "Plugin Admin",
"id": "grafana-plugin-admin-app",
"backend": false,
"autoEnabled": true,
"pinned": false,
"info": {
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
"keywords": ["plugins"],
"logos": {
"small": "img/logo.svg",
"large": "img/logo.svg"
},
"links": [],
"screenshots": [],
"version": "%VERSION%",
"updated": "%TODAY%"
},
"dependencies": {
"grafanaDependency": ">=8.0.0",
"grafanaVersion": "8.0.x",
"plugins": []
}
}
@@ -0,0 +1,75 @@
export type PluginTypeCode = 'app' | 'panel' | 'datasource';
export interface Plugin {
name: string;
description: string;
slug: string;
orgName: string;
orgSlug: string;
signatureType: string;
version: string;
status: string;
popularity: number;
downloads: number;
updatedAt: string;
createdAt: string;
typeCode: string;
featured: number;
readme: string;
internal: boolean;
versionSignatureType: string;
packages: {
[arch: string]: {
packageName: string;
downloadUrl: string;
};
};
links: Array<{
rel: string;
href: string;
}>;
json: {
dependencies: {
grafanaDependency: string;
grafanaVersion: string;
};
info: {
links: Array<{
name: string;
url: string;
}>;
};
};
}
export type Metadata = {
info: {
version: string;
links: Array<{
name: string;
url: string;
}>;
};
dev: boolean;
};
export interface Version {
version: string;
createdAt: string;
}
export interface PluginDetails {
remote?: Plugin;
remoteVersions?: Version[];
local?: Metadata;
}
export interface Org {
slug: string;
name: string;
url: string;
createdAt: string;
updatedAt: string;
avatar: string;
avatarUrl: string;
}