mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Plugin admin: Add a page to show where panel plugins are used in dashboards (#50909)
This commit is contained in:
@@ -46,6 +46,7 @@ export type DashboardPageRouteSearchParams = {
|
||||
editPanel?: string;
|
||||
viewPanel?: string;
|
||||
editview?: string;
|
||||
panelType?: string;
|
||||
inspect?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
@@ -129,6 +130,7 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
|
||||
urlUid: match.params.uid,
|
||||
urlType: match.params.type,
|
||||
urlFolderId: queryParams.folderId,
|
||||
panelType: queryParams.panelType,
|
||||
routeName: this.props.route.routeName,
|
||||
fixUrl: !isPublic,
|
||||
accessToken: match.params.accessToken,
|
||||
|
||||
@@ -24,7 +24,8 @@ export interface InitDashboardArgs {
|
||||
urlUid?: string;
|
||||
urlSlug?: string;
|
||||
urlType?: string;
|
||||
urlFolderId?: string | null;
|
||||
urlFolderId?: string;
|
||||
panelType?: string;
|
||||
accessToken?: string;
|
||||
routeName?: string;
|
||||
fixUrl: boolean;
|
||||
@@ -84,7 +85,7 @@ async function fetchDashboard(
|
||||
return dashDTO;
|
||||
}
|
||||
case DashboardRoutes.New: {
|
||||
return getNewDashboardModelData(args.urlFolderId);
|
||||
return getNewDashboardModelData(args.urlFolderId, args.panelType);
|
||||
}
|
||||
default:
|
||||
throw { message: 'Unknown route ' + args.routeName };
|
||||
@@ -213,7 +214,7 @@ export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
|
||||
};
|
||||
}
|
||||
|
||||
export function getNewDashboardModelData(urlFolderId?: string | null): any {
|
||||
export function getNewDashboardModelData(urlFolderId?: string, panelType?: string): any {
|
||||
const data = {
|
||||
meta: {
|
||||
canStar: false,
|
||||
@@ -226,7 +227,7 @@ export function getNewDashboardModelData(urlFolderId?: string | null): any {
|
||||
title: 'New dashboard',
|
||||
panels: [
|
||||
{
|
||||
type: 'add-panel',
|
||||
type: panelType ?? 'add-panel',
|
||||
gridPos: { x: 0, y: 0, w: 12, h: 9 },
|
||||
title: 'Panel Title',
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import { CatalogPlugin, PluginTabIds } from '../types';
|
||||
|
||||
import { AppConfigCtrlWrapper } from './AppConfigWrapper';
|
||||
import { PluginDashboards } from './PluginDashboards';
|
||||
import { PluginUsage } from './PluginUsage';
|
||||
|
||||
type Props = {
|
||||
plugin: CatalogPlugin;
|
||||
@@ -60,6 +61,14 @@ export function PluginDetailsBody({ plugin, queryParams, pageId }: Props): JSX.E
|
||||
}
|
||||
}
|
||||
|
||||
if (pageId === PluginTabIds.USAGE && pluginConfig) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<PluginUsage plugin={pluginConfig?.meta} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pageId === PluginTabIds.DASHBOARDS && pluginConfig) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@@ -78,6 +87,7 @@ export function PluginDetailsBody({ plugin, queryParams, pageId }: Props): JSX.E
|
||||
export const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css`
|
||||
padding: ${theme.spacing(3, 4)};
|
||||
height: 100%;
|
||||
`,
|
||||
readme: css`
|
||||
& img {
|
||||
|
||||
90
public/app/features/plugins/admin/components/PluginUsage.tsx
Normal file
90
public/app/features/plugins/admin/components/PluginUsage.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { GrafanaTheme2, PluginMeta } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Alert, Spinner, useStyles2 } from '@grafana/ui';
|
||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||
import { SearchResultsTable } from 'app/features/search/page/components/SearchResultsTable';
|
||||
import { getGrafanaSearcher, SearchQuery } from 'app/features/search/service';
|
||||
|
||||
type Props = {
|
||||
plugin: PluginMeta;
|
||||
};
|
||||
|
||||
export function PluginUsage({ plugin }: Props) {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const searchQuery = useMemo<SearchQuery>(() => {
|
||||
return {
|
||||
query: '*',
|
||||
panel_type: plugin.id,
|
||||
kind: ['panel'],
|
||||
};
|
||||
}, [plugin]);
|
||||
|
||||
const results = useAsync(() => {
|
||||
return getGrafanaSearcher().search(searchQuery);
|
||||
}, [searchQuery]);
|
||||
|
||||
const found = results.value;
|
||||
if (found?.totalRows) {
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.info}>
|
||||
{plugin.name} is used <b>{found.totalRows}</b> times.
|
||||
</div>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => {
|
||||
return (
|
||||
<SearchResultsTable
|
||||
response={found}
|
||||
width={width}
|
||||
height={height}
|
||||
clearSelection={() => {}}
|
||||
keyboardEvents={of()}
|
||||
onTagSelected={() => {}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.loading) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
if (!config.featureToggles.panelTitleSearch) {
|
||||
return (
|
||||
<Alert title="Missing feature toggle: panelTitleSearch">
|
||||
Plugin usage requires the new search index to find usage across dashboards
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EmptyListCTA
|
||||
title={`${plugin.name} is not used in any dashboards yet`}
|
||||
buttonIcon="plus"
|
||||
buttonTitle="Create Dashboard"
|
||||
buttonLink={`dashboard/new?panelType=${plugin.id}&editPanel=1`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
wrap: css`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
`,
|
||||
info: css`
|
||||
padding-bottom: 30px;
|
||||
`,
|
||||
};
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { PluginIncludeType, PluginType } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
|
||||
import { usePluginConfig } from '../hooks/usePluginConfig';
|
||||
import { isOrgAdmin } from '../permissions';
|
||||
@@ -23,7 +24,6 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: Plugin
|
||||
const canConfigurePlugins = isOrgAdmin();
|
||||
const tabs: PluginDetailsTab[] = [...defaultTabs];
|
||||
let defaultTab;
|
||||
|
||||
if (isPublished) {
|
||||
tabs.push({
|
||||
label: PluginTabLabels.VERSIONS,
|
||||
@@ -39,6 +39,15 @@ export const usePluginDetailsTabs = (plugin?: CatalogPlugin, defaultTabs: Plugin
|
||||
return [tabs, defaultTab];
|
||||
}
|
||||
|
||||
if (config.featureToggles.panelTitleSearch && pluginConfig.meta.type === PluginType.panel) {
|
||||
tabs.push({
|
||||
label: PluginTabLabels.USAGE,
|
||||
icon: 'list-ul',
|
||||
id: PluginTabIds.USAGE,
|
||||
href: `${pathname}?page=${PluginTabIds.USAGE}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (canConfigurePlugins) {
|
||||
if (pluginConfig.meta.type === PluginType.app) {
|
||||
if (pluginConfig.angularConfigCtrl) {
|
||||
|
||||
@@ -114,6 +114,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
||||
// Needed due to block formatting context
|
||||
tabContent: css`
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -212,6 +212,7 @@ export enum PluginTabLabels {
|
||||
VERSIONS = 'Version history',
|
||||
CONFIG = 'Config',
|
||||
DASHBOARDS = 'Dashboards',
|
||||
USAGE = 'Usage',
|
||||
}
|
||||
|
||||
export enum PluginTabIds {
|
||||
@@ -219,6 +220,7 @@ export enum PluginTabIds {
|
||||
VERSIONS = 'version-history',
|
||||
CONFIG = 'config',
|
||||
DASHBOARDS = 'dashboards',
|
||||
USAGE = 'usage',
|
||||
}
|
||||
|
||||
export enum RequestStatus {
|
||||
|
||||
@@ -84,7 +84,8 @@ export const SearchResultsTable = React.memo(
|
||||
clearSelection,
|
||||
styles,
|
||||
onTagSelected,
|
||||
onDatasourceChange
|
||||
onDatasourceChange,
|
||||
response.view?.length >= response.totalRows
|
||||
);
|
||||
}, [response, width, styles, selection, selectionToggle, clearSelection, onTagSelected, onDatasourceChange]);
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ export const generateColumns = (
|
||||
clearSelection: () => void,
|
||||
styles: { [key: string]: string },
|
||||
onTagSelected: (tag: string) => void,
|
||||
onDatasourceChange?: (datasource?: string) => void
|
||||
onDatasourceChange?: (datasource?: string) => void,
|
||||
showingEverything?: boolean
|
||||
): TableColumn[] => {
|
||||
const columns: TableColumn[] = [];
|
||||
const access = response.view.fields;
|
||||
@@ -125,9 +126,26 @@ export const generateColumns = (
|
||||
columns.push(makeTypeColumn(access.kind, access.panel_type, width, styles));
|
||||
availableWidth -= width;
|
||||
|
||||
// Show datasources if we have any
|
||||
if (access.ds_uid && onDatasourceChange) {
|
||||
width = Math.min(availableWidth / 2.5, DATASOURCE_COLUMN_WIDTH);
|
||||
columns.push(
|
||||
makeDataSourceColumn(
|
||||
access.ds_uid,
|
||||
width,
|
||||
styles.typeIcon,
|
||||
styles.datasourceItem,
|
||||
styles.invalidDatasourceItem,
|
||||
onDatasourceChange
|
||||
)
|
||||
);
|
||||
availableWidth -= width;
|
||||
}
|
||||
|
||||
const showTags = !showingEverything || hasValue(response.view.fields.tags);
|
||||
const meta = response.view.dataFrame.meta?.custom as SearchResultMeta;
|
||||
if (meta?.locationInfo && availableWidth > 0) {
|
||||
width = Math.max(availableWidth / 1.75, 300);
|
||||
width = showTags ? Math.max(availableWidth / 1.75, 300) : availableWidth;
|
||||
availableWidth -= width;
|
||||
columns.push({
|
||||
Cell: (p) => {
|
||||
@@ -154,23 +172,7 @@ export const generateColumns = (
|
||||
});
|
||||
}
|
||||
|
||||
// Show datasources if we have any
|
||||
if (access.ds_uid && onDatasourceChange) {
|
||||
width = DATASOURCE_COLUMN_WIDTH;
|
||||
columns.push(
|
||||
makeDataSourceColumn(
|
||||
access.ds_uid,
|
||||
width,
|
||||
styles.typeIcon,
|
||||
styles.datasourceItem,
|
||||
styles.invalidDatasourceItem,
|
||||
onDatasourceChange
|
||||
)
|
||||
);
|
||||
availableWidth -= width;
|
||||
}
|
||||
|
||||
if (availableWidth > 0) {
|
||||
if (availableWidth > 0 && showTags) {
|
||||
columns.push(makeTagsColumn(access.tags, availableWidth, styles.tagList, onTagSelected));
|
||||
}
|
||||
|
||||
@@ -209,6 +211,15 @@ function getIconForKind(v: string): IconName {
|
||||
return 'question-circle';
|
||||
}
|
||||
|
||||
function hasValue(f: Field): boolean {
|
||||
for (let i = 0; i < f.values.length; i++) {
|
||||
if (f.values.get(i) != null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function makeDataSourceColumn(
|
||||
field: Field<string[]>,
|
||||
width: number,
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface SearchQuery {
|
||||
ds_uid?: string;
|
||||
tags?: string[];
|
||||
kind?: string[];
|
||||
panel_type?: string;
|
||||
uid?: string[];
|
||||
id?: number[];
|
||||
facet?: FacetField[];
|
||||
|
||||
Reference in New Issue
Block a user