From 013f1b8d19362b29fd05e4699d71e57e13701e3f Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 2 May 2019 10:15:39 -0700 Subject: [PATCH] App Plugins: support react pages and tabs (#16586) --- packages/grafana-ui/src/types/app.ts | 62 +++ packages/grafana-ui/src/types/datasource.ts | 23 +- packages/grafana-ui/src/types/index.ts | 1 + packages/grafana-ui/src/types/plugin.ts | 79 ++-- pkg/api/api.go | 4 +- pkg/api/plugins.go | 2 +- public/app/core/nav_model_srv.ts | 29 +- .../features/datasources/DashboardsTable.tsx | 4 +- public/app/features/plugins/AppRootPage.tsx | 103 +++++ .../app/features/plugins/PluginDashboards.tsx | 112 +++++ .../app/features/plugins/PluginListItem.tsx | 2 +- public/app/features/plugins/PluginPage.tsx | 415 ++++++++++++++++++ .../PluginListItem.test.tsx.snap | 4 +- public/app/features/plugins/all.ts | 2 - .../plugins/import_list/import_list.html | 30 -- .../plugins/import_list/import_list.ts | 92 ---- .../plugins/partials/plugin_edit.html | 69 --- .../plugins/partials/update_instructions.html | 6 +- .../app/features/plugins/plugin_component.ts | 6 +- .../app/features/plugins/plugin_edit_ctrl.ts | 180 -------- .../plugins/wrappers/AppConfigWrapper.tsx | 139 ++++++ .../app/example-app/ExampleRootPage.tsx | 102 +++++ .../app/example-app/config/ExampleTab1.tsx | 25 ++ .../app/example-app/config/ExampleTab2.tsx | 25 ++ .../app/example-app/dashboards/stats.json | 110 +++++ .../app/example-app/dashboards/streaming.json | 83 ++++ public/app/plugins/app/example-app/module.ts | 23 +- .../app/plugins/app/example-app/plugin.json | 14 + public/app/routes/routes.ts | 20 +- 29 files changed, 1316 insertions(+), 450 deletions(-) create mode 100644 packages/grafana-ui/src/types/app.ts create mode 100644 public/app/features/plugins/AppRootPage.tsx create mode 100644 public/app/features/plugins/PluginDashboards.tsx create mode 100644 public/app/features/plugins/PluginPage.tsx delete mode 100644 public/app/features/plugins/import_list/import_list.html delete mode 100644 public/app/features/plugins/import_list/import_list.ts delete mode 100644 public/app/features/plugins/partials/plugin_edit.html delete mode 100644 public/app/features/plugins/plugin_edit_ctrl.ts create mode 100644 public/app/features/plugins/wrappers/AppConfigWrapper.tsx create mode 100644 public/app/plugins/app/example-app/ExampleRootPage.tsx create mode 100644 public/app/plugins/app/example-app/config/ExampleTab1.tsx create mode 100644 public/app/plugins/app/example-app/config/ExampleTab2.tsx create mode 100644 public/app/plugins/app/example-app/dashboards/stats.json create mode 100644 public/app/plugins/app/example-app/dashboards/streaming.json diff --git a/packages/grafana-ui/src/types/app.ts b/packages/grafana-ui/src/types/app.ts new file mode 100644 index 00000000000..01dd6e1a40a --- /dev/null +++ b/packages/grafana-ui/src/types/app.ts @@ -0,0 +1,62 @@ +import { ComponentClass } from 'react'; +import { NavModel } from './navModel'; +import { PluginMeta, PluginIncludeType, GrafanaPlugin } from './plugin'; + +export interface AppRootProps { + meta: AppPluginMeta; + + path: string; // The URL path to this page + query: { [s: string]: any }; // The URL query parameters + + /** + * Pass the nav model to the container... is there a better way? + */ + onNavChanged: (nav: NavModel) => void; +} + +export interface AppPluginMeta extends PluginMeta { + // TODO anything specific to apps? +} + +export class AppPlugin extends GrafanaPlugin { + // Content under: /a/${plugin-id}/* + root?: ComponentClass; + rootNav?: NavModel; // Initial navigation model + + // Old style pages + angularPages?: { [component: string]: any }; + + /** + * Set the component displayed under: + * /a/${plugin-id}/* + */ + setRootPage(root: ComponentClass, rootNav?: NavModel) { + this.root = root; + this.rootNav = rootNav; + return this; + } + + setComponentsFromLegacyExports(pluginExports: any) { + if (pluginExports.ConfigCtrl) { + this.angularConfigCtrl = pluginExports.ConfigCtrl; + } + + const { meta } = this; + if (meta && meta.includes) { + for (const include of meta.includes) { + const { type, component } = include; + if (type === PluginIncludeType.page && component) { + const exp = pluginExports[component]; + if (!exp) { + console.warn('App Page uses unknown component: ', component, meta); + continue; + } + if (!this.angularPages) { + this.angularPages = {}; + } + this.angularPages[component] = exp; + } + } + } + } +} diff --git a/packages/grafana-ui/src/types/datasource.ts b/packages/grafana-ui/src/types/datasource.ts index 015d7cd10a1..3803bf7eca4 100644 --- a/packages/grafana-ui/src/types/datasource.ts +++ b/packages/grafana-ui/src/types/datasource.ts @@ -25,11 +25,6 @@ export class DataSourcePlugin { QueryCtrl?: any; - ConfigCtrl?: any; AnnotationsQueryCtrl?: any; VariableQueryEditor?: any; QueryEditor?: ComponentClass>; diff --git a/packages/grafana-ui/src/types/index.ts b/packages/grafana-ui/src/types/index.ts index 2980686cd3b..64092fd9dea 100644 --- a/packages/grafana-ui/src/types/index.ts +++ b/packages/grafana-ui/src/types/index.ts @@ -2,6 +2,7 @@ export * from './data'; export * from './time'; export * from './panel'; export * from './plugin'; +export * from './app'; export * from './datasource'; export * from './theme'; export * from './graph'; diff --git a/packages/grafana-ui/src/types/plugin.ts b/packages/grafana-ui/src/types/plugin.ts index af5382426ba..c083fd95be3 100644 --- a/packages/grafana-ui/src/types/plugin.ts +++ b/packages/grafana-ui/src/types/plugin.ts @@ -1,3 +1,5 @@ +import { ComponentClass } from 'react'; + export enum PluginState { alpha = 'alpha', // Only included it `enable_alpha` is true beta = 'beta', // Will show a warning banner @@ -21,8 +23,12 @@ export interface PluginMeta { module: string; baseUrl: string; + // Define plugin requirements + dependencies?: PluginDependencies; + // Filled in by the backend jsonData?: { [str: string]: any }; + secureJsonData?: { [str: string]: any }; enabled?: boolean; defaultNavUrl?: string; hasUpdate?: boolean; @@ -30,6 +36,18 @@ export interface PluginMeta { pinned?: boolean; } +interface PluginDependencyInfo { + id: string; + name: string; + version: string; + type: PluginType; +} + +export interface PluginDependencies { + grafanaVersion: string; + plugins: PluginDependencyInfo[]; +} + export enum PluginIncludeType { dashboard = 'dashboard', page = 'page', @@ -44,6 +62,10 @@ export interface PluginInclude { name: string; path?: string; icon?: string; + + role?: string; // "Viewer", Admin, editor??? + addToNav?: boolean; // Show in the sidebar... only if type=page? + // Angular app pages component?: string; } @@ -69,44 +91,35 @@ export interface PluginMetaInfo { version: string; } -export class GrafanaPlugin { +export interface PluginConfigTabProps { + meta: T; + query: { [s: string]: any }; // The URL query parameters +} + +export interface PluginConfigTab { + title: string; // Display + icon?: string; + id: string; // Unique, in URL + + body: ComponentClass>; +} + +export class GrafanaPlugin { // Meta is filled in by the plugin loading system meta?: T; - // Soon this will also include common config options -} + // Config control (app/datasource) + angularConfigCtrl?: any; -export class AppPlugin extends GrafanaPlugin { - angular?: { - ConfigCtrl?: any; - pages: { [component: string]: any }; - }; + // Show configuration tabs on the plugin page + configTabs?: Array>; - setComponentsFromLegacyExports(pluginExports: any) { - const legacy = { - ConfigCtrl: undefined, - pages: {} as any, - }; - - if (pluginExports.ConfigCtrl) { - legacy.ConfigCtrl = pluginExports.ConfigCtrl; - this.angular = legacy; - } - - const { meta } = this; - if (meta && meta.includes) { - for (const include of meta.includes) { - const { type, component } = include; - if (type === PluginIncludeType.page && component) { - const exp = pluginExports[component]; - if (!exp) { - console.warn('App Page uses unknown component: ', component, meta); - continue; - } - legacy.pages[component] = exp; - this.angular = legacy; - } - } + // Tabs on the plugin page + addConfigTab(tab: PluginConfigTab) { + if (!this.configTabs) { + this.configTabs = []; } + this.configTabs.push(tab); + return this; } } diff --git a/pkg/api/api.go b/pkg/api/api.go index df532442f86..e095986a229 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -59,8 +59,10 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/styleguide", reqSignedIn, hs.Index) r.Get("/plugins", reqSignedIn, hs.Index) - r.Get("/plugins/:id/edit", reqSignedIn, hs.Index) + r.Get("/plugins/:id/", reqSignedIn, hs.Index) + r.Get("/plugins/:id/edit", reqSignedIn, hs.Index) // deprecated r.Get("/plugins/:id/page/:page", reqSignedIn, hs.Index) + r.Get("/a/:id/*", reqSignedIn, hs.Index) // App Root Page r.Get("/d/:uid/:slug", reqSignedIn, hs.Index) r.Get("/d/:uid", reqSignedIn, hs.Index) diff --git a/pkg/api/plugins.go b/pkg/api/plugins.go index 4e4ff525ff4..bec33313d0d 100644 --- a/pkg/api/plugins.go +++ b/pkg/api/plugins.go @@ -60,7 +60,7 @@ func (hs *HTTPServer) GetPluginList(c *m.ReqContext) Response { } if listItem.DefaultNavUrl == "" || !listItem.Enabled { - listItem.DefaultNavUrl = setting.AppSubUrl + "/plugins/" + listItem.Id + "/edit" + listItem.DefaultNavUrl = setting.AppSubUrl + "/plugins/" + listItem.Id + "/" } // filter out disabled diff --git a/public/app/core/nav_model_srv.ts b/public/app/core/nav_model_srv.ts index a394511aa2c..bf31c2169c5 100644 --- a/public/app/core/nav_model_srv.ts +++ b/public/app/core/nav_model_srv.ts @@ -49,18 +49,25 @@ export class NavModelSrv { } getNotFoundNav() { - const node = { - text: 'Page not found', - icon: 'fa fa-fw fa-warning', - subTitle: '404 Error', - }; - - return { - breadcrumbs: [node], - node: node, - main: node, - }; + return getNotFoundNav(); // the exported function } } +export function getNotFoundNav(): NavModel { + return getWarningNav('Page not found', '404 Error'); +} + +export function getWarningNav(text: string, subTitle?: string): NavModel { + const node = { + text, + subTitle, + icon: 'fa fa-fw fa-warning', + }; + return { + breadcrumbs: [node], + node: node, + main: node, + }; +} + coreModule.service('navModelSrv', NavModelSrv); diff --git a/public/app/features/datasources/DashboardsTable.tsx b/public/app/features/datasources/DashboardsTable.tsx index 6c6bad8c88e..ae5e66de950 100644 --- a/public/app/features/datasources/DashboardsTable.tsx +++ b/public/app/features/datasources/DashboardsTable.tsx @@ -3,8 +3,8 @@ import { PluginDashboard } from '../../types'; export interface Props { dashboards: PluginDashboard[]; - onImport: (dashboard, overwrite) => void; - onRemove: (dashboard) => void; + onImport: (dashboard: PluginDashboard, overwrite: boolean) => void; + onRemove: (dashboard: PluginDashboard) => void; } const DashboardsTable: FC = ({ dashboards, onImport, onRemove }) => { diff --git a/public/app/features/plugins/AppRootPage.tsx b/public/app/features/plugins/AppRootPage.tsx new file mode 100644 index 00000000000..8c337670890 --- /dev/null +++ b/public/app/features/plugins/AppRootPage.tsx @@ -0,0 +1,103 @@ +// Libraries +import React, { Component } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; + +// Types +import { StoreState, UrlQueryMap } from 'app/types'; + +import Page from 'app/core/components/Page/Page'; +import { getPluginSettings } from './PluginSettingsCache'; +import { importAppPlugin } from './plugin_loader'; +import { AppPlugin, NavModel, AppPluginMeta, PluginType } from '@grafana/ui'; +import { getLoadingNav } from './PluginPage'; +import { getNotFoundNav, getWarningNav } from 'app/core/nav_model_srv'; +import { appEvents } from 'app/core/core'; + +interface Props { + pluginId: string; // From the angular router + query: UrlQueryMap; + path: string; + slug?: string; +} + +interface State { + loading: boolean; + plugin?: AppPlugin; + nav: NavModel; +} + +export function getAppPluginPageError(meta: AppPluginMeta) { + if (!meta) { + return 'Unknown Plugin'; + } + if (meta.type !== PluginType.app) { + return 'Plugin must be an app'; + } + if (!meta.enabled) { + return 'Applicaiton Not Enabled'; + } + return null; +} + +class AppRootPage extends Component { + constructor(props: Props) { + super(props); + this.state = { + loading: true, + nav: getLoadingNav(), + }; + } + + async componentDidMount() { + const { pluginId } = this.props; + + try { + const app = await getPluginSettings(pluginId).then(info => { + const error = getAppPluginPageError(info); + if (error) { + appEvents.emit('alert-error', [error]); + this.setState({ nav: getWarningNav(error) }); + return null; + } + return importAppPlugin(info); + }); + this.setState({ plugin: app, loading: false }); + } catch (err) { + this.setState({ plugin: null, loading: false, nav: getNotFoundNav() }); + } + } + + onNavChanged = (nav: NavModel) => { + this.setState({ nav }); + }; + + render() { + const { path, query } = this.props; + const { loading, plugin, nav } = this.state; + + if (plugin && !plugin.root) { + // TODO? redirect to plugin page? + return
No Root App
; + } + + return ( + + + {!loading && plugin && ( + + )} + + + ); + } +} + +const mapStateToProps = (state: StoreState) => ({ + pluginId: state.location.routeParams.pluginId, + slug: state.location.routeParams.slug, + query: state.location.query, + path: state.location.path, +}); + +export default hot(module)(connect(mapStateToProps)(AppRootPage)); diff --git a/public/app/features/plugins/PluginDashboards.tsx b/public/app/features/plugins/PluginDashboards.tsx new file mode 100644 index 00000000000..2b48cb55df6 --- /dev/null +++ b/public/app/features/plugins/PluginDashboards.tsx @@ -0,0 +1,112 @@ +import React, { PureComponent } from 'react'; + +import extend from 'lodash/extend'; + +import { PluginMeta, DataSourceApi } from '@grafana/ui'; +import { PluginDashboard } from 'app/types'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { appEvents } from 'app/core/core'; +import DashboardsTable from 'app/features/datasources/DashboardsTable'; + +interface Props { + plugin: PluginMeta; + datasource?: DataSourceApi; +} + +interface State { + dashboards: PluginDashboard[]; + loading: boolean; +} + +export class PluginDashboards extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { + loading: true, + dashboards: [], + }; + } + + async componentDidMount() { + const pluginId = this.props.plugin.id; + getBackendSrv() + .get(`/api/plugins/${pluginId}/dashboards`) + .then((dashboards: any) => { + this.setState({ dashboards, loading: false }); + }); + } + + importAll = () => { + this.importNext(0); + }; + + private importNext = (index: number) => { + const { dashboards } = this.state; + return this.import(dashboards[index], true).then(() => { + if (index + 1 < dashboards.length) { + return new Promise(resolve => { + setTimeout(() => { + this.importNext(index + 1).then(() => { + resolve(); + }); + }, 500); + }); + } else { + return Promise.resolve(); + } + }); + }; + + import = (dash: PluginDashboard, overwrite: boolean) => { + const { plugin, datasource } = this.props; + + const installCmd = { + pluginId: plugin.id, + path: dash.path, + overwrite: overwrite, + inputs: [], + }; + + if (datasource) { + installCmd.inputs.push({ + name: '*', + type: 'datasource', + pluginId: datasource.meta.id, + value: datasource.name, + }); + } + + return getBackendSrv() + .post(`/api/dashboards/import`, installCmd) + .then((res: PluginDashboard) => { + appEvents.emit('alert-success', ['Dashboard Imported', dash.title]); + extend(dash, res); + this.setState({ dashboards: [...this.state.dashboards] }); + }); + }; + + remove = (dash: PluginDashboard) => { + getBackendSrv() + .delete('/api/dashboards/' + dash.importedUri) + .then(() => { + dash.imported = false; + this.setState({ dashboards: [...this.state.dashboards] }); + }); + }; + + render() { + const { loading, dashboards } = this.state; + if (loading) { + return
loading...
; + } + if (!dashboards || !dashboards.length) { + return
No dashboards are included with this plugin
; + } + + return ( +
+ +
+ ); + } +} diff --git a/public/app/features/plugins/PluginListItem.tsx b/public/app/features/plugins/PluginListItem.tsx index a5b22c25d74..bafd7d63633 100644 --- a/public/app/features/plugins/PluginListItem.tsx +++ b/public/app/features/plugins/PluginListItem.tsx @@ -19,7 +19,7 @@ const PluginListItem: FC = props => { return (
  • - +
    diff --git a/public/app/features/plugins/PluginPage.tsx b/public/app/features/plugins/PluginPage.tsx new file mode 100644 index 00000000000..176c03c6b6c --- /dev/null +++ b/public/app/features/plugins/PluginPage.tsx @@ -0,0 +1,415 @@ +// Libraries +import React, { PureComponent } from 'react'; +import { hot } from 'react-hot-loader'; +import { connect } from 'react-redux'; +import find from 'lodash/find'; + +// Types +import { StoreState, UrlQueryMap } from 'app/types'; +import { + NavModel, + NavModelItem, + PluginType, + GrafanaPlugin, + PluginInclude, + PluginDependencies, + PluginMeta, + PluginMetaInfo, + Tooltip, + AppPlugin, + PluginIncludeType, +} from '@grafana/ui'; + +import Page from 'app/core/components/Page/Page'; +import { getPluginSettings } from './PluginSettingsCache'; +import { importAppPlugin, importDataSourcePlugin, importPanelPlugin } from './plugin_loader'; +import { getNotFoundNav } from 'app/core/nav_model_srv'; +import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp'; +import { AppConfigCtrlWrapper } from './wrappers/AppConfigWrapper'; +import { PluginDashboards } from './PluginDashboards'; +import { appEvents } from 'app/core/core'; + +export function getLoadingNav(): NavModel { + const node = { + text: 'Loading...', + icon: 'icon-gf icon-gf-panel', + }; + return { + node: node, + main: node, + }; +} + +function loadPlugin(pluginId: string): Promise { + return getPluginSettings(pluginId).then(info => { + if (info.type === PluginType.app) { + return importAppPlugin(info); + } + if (info.type === PluginType.datasource) { + return importDataSourcePlugin(info); + } + if (info.type === PluginType.panel) { + return importPanelPlugin(pluginId).then(plugin => { + // Panel Meta does not have the *full* settings meta + return getPluginSettings(pluginId).then(meta => { + plugin.meta = { + ...meta, // Set any fields that do not exist + ...plugin.meta, + }; + return plugin; + }); + }); + } + return Promise.reject('Unknown Plugin type: ' + info.type); + }); +} + +interface Props { + pluginId: string; + query: UrlQueryMap; + path: string; // the URL path +} + +interface State { + loading: boolean; + plugin?: GrafanaPlugin; + nav: NavModel; + defaultTab: string; // The first configured one or readme +} + +const TAB_ID_README = 'readme'; +const TAB_ID_DASHBOARDS = 'dashboards'; +const TAB_ID_CONFIG_CTRL = 'config'; + +class PluginPage extends PureComponent { + constructor(props: Props) { + super(props); + this.state = { + loading: true, + nav: getLoadingNav(), + defaultTab: TAB_ID_README, + }; + } + + async componentDidMount() { + const { pluginId, path, query } = this.props; + const plugin = await loadPlugin(pluginId); + if (!plugin) { + this.setState({ + loading: false, + nav: getNotFoundNav(), + }); + return; // 404 + } + const { meta } = plugin; + + let defaultTab: string; + const tabs: NavModelItem[] = []; + if (true) { + tabs.push({ + text: 'Readme', + icon: 'fa fa-fw fa-file-text-o', + url: path + '?tab=' + TAB_ID_README, + id: TAB_ID_README, + }); + } + + // Only show Config/Pages for app + if (meta.type === PluginType.app) { + // Legacy App Config + if (plugin.angularConfigCtrl) { + tabs.push({ + text: 'Config', + icon: 'gicon gicon-cog', + url: path + '?tab=' + TAB_ID_CONFIG_CTRL, + id: TAB_ID_CONFIG_CTRL, + }); + defaultTab = TAB_ID_CONFIG_CTRL; + } + + if (plugin.configTabs) { + for (const tab of plugin.configTabs) { + tabs.push({ + text: tab.title, + icon: tab.icon, + url: path + '?tab=' + tab.id, + id: tab.id, + }); + if (!defaultTab) { + defaultTab = tab.id; + } + } + } + + // Check for the dashboard tabs + if (find(meta.includes, { type: 'dashboard' })) { + tabs.push({ + text: 'Dashboards', + icon: 'gicon gicon-dashboard', + url: path + '?tab=' + TAB_ID_DASHBOARDS, + id: TAB_ID_DASHBOARDS, + }); + } + } + + if (!defaultTab) { + defaultTab = tabs[0].id; // the first tab + } + + const node = { + text: meta.name, + img: meta.info.logos.large, + subTitle: meta.info.author.name, + breadcrumbs: [{ title: 'Plugins', url: '/plugins' }], + url: path, + children: this.setActiveTab(query.tab as string, tabs, defaultTab), + }; + + this.setState({ + loading: false, + plugin, + defaultTab, + nav: { + node: node, + main: node, + }, + }); + } + + setActiveTab(tabId: string, tabs: NavModelItem[], defaultTabId: string): NavModelItem[] { + let found = false; + const selected = tabId || defaultTabId; + const changed = tabs.map(tab => { + const active = !found && selected === tab.id; + if (active) { + found = true; + } + return { ...tab, active }; + }); + if (!found) { + changed[0].active = true; + } + return changed; + } + + componentDidUpdate(prevProps: Props) { + const prevTab = prevProps.query.tab as string; + const tab = this.props.query.tab as string; + if (prevTab !== tab) { + const { nav, defaultTab } = this.state; + const node = { + ...nav.node, + children: this.setActiveTab(tab, nav.node.children, defaultTab), + }; + this.setState({ + nav: { + node: node, + main: node, + }, + }); + } + } + + renderBody() { + const { query } = this.props; + const { plugin, nav } = this.state; + + if (!plugin) { + return
    Plugin not found.
    ; + } + + const active = nav.main.children.find(tab => tab.active); + if (active) { + // Find the current config tab + if (plugin.configTabs) { + for (const tab of plugin.configTabs) { + if (tab.id === active.id) { + return ; + } + } + } + + // Apps have some special behavior + if (plugin.meta.type === PluginType.app) { + if (active.id === TAB_ID_DASHBOARDS) { + return ; + } + + if (active.id === TAB_ID_CONFIG_CTRL && plugin.angularConfigCtrl) { + return ; + } + } + } + + return ; + } + + showUpdateInfo = () => { + appEvents.emit('show-modal', { + src: 'public/app/features/plugins/partials/update_instructions.html', + model: this.state.plugin.meta, + }); + }; + + renderVersionInfo(meta: PluginMeta) { + if (!meta.info.version) { + return null; + } + + return ( +
    + ); + } + + renderSidebarIncludeBody(item: PluginInclude) { + if (item.type === PluginIncludeType.page) { + const pluginId = this.state.plugin.meta.id; + const page = item.name.toLowerCase().replace(' ', '-'); + return ( + + + {item.name} + + ); + } + return ( + <> + + {item.name} + + ); + } + + renderSidebarIncludes(includes: PluginInclude[]) { + if (!includes || !includes.length) { + return null; + } + + return ( +
    +

    Includes

    +
      + {includes.map(include => { + return ( +
    • + {this.renderSidebarIncludeBody(include)} +
    • + ); + })} +
    +
    + ); + } + + renderSidebarDependencies(dependencies: PluginDependencies) { + if (!dependencies) { + return null; + } + + return ( +
    +

    Dependencies

    +
      +
    • + + Grafana {dependencies.grafanaVersion} +
    • + {dependencies.plugins && + dependencies.plugins.map(plug => { + return ( +
    • + + {plug.name} {plug.version} +
    • + ); + })} +
    +
    + ); + } + + renderSidebarLinks(info: PluginMetaInfo) { + if (!info.links || !info.links.length) { + return null; + } + + return ( +
    +

    Links

    + +
    + ); + } + + render() { + const { loading, nav, plugin } = this.state; + return ( + + + {!loading && ( +
    +
    {this.renderBody()}
    + +
    + )} +
    +
    + ); + } +} + +function getPluginIcon(type: string) { + switch (type) { + case 'datasource': + return 'gicon gicon-datasources'; + case 'panel': + return 'icon-gf icon-gf-panel'; + case 'app': + return 'icon-gf icon-gf-apps'; + case 'page': + return 'icon-gf icon-gf-endpoint-tiny'; + case 'dashboard': + return 'gicon gicon-dashboard'; + default: + return 'icon-gf icon-gf-apps'; + } +} + +const mapStateToProps = (state: StoreState) => ({ + pluginId: state.location.routeParams.pluginId, + query: state.location.query, + path: state.location.path, +}); + +export default hot(module)(connect(mapStateToProps)(PluginPage)); diff --git a/public/app/features/plugins/__snapshots__/PluginListItem.test.tsx.snap b/public/app/features/plugins/__snapshots__/PluginListItem.test.tsx.snap index 46965f9ab81..694e8f04156 100644 --- a/public/app/features/plugins/__snapshots__/PluginListItem.test.tsx.snap +++ b/public/app/features/plugins/__snapshots__/PluginListItem.test.tsx.snap @@ -6,7 +6,7 @@ exports[`Render should render component 1`] = ` >
    - - - - - - - - -
    - - - - {{dash.title}} - - {{dash.title}} - - - - -
    -
    - diff --git a/public/app/features/plugins/import_list/import_list.ts b/public/app/features/plugins/import_list/import_list.ts deleted file mode 100644 index 90ebcff9559..00000000000 --- a/public/app/features/plugins/import_list/import_list.ts +++ /dev/null @@ -1,92 +0,0 @@ -import _ from 'lodash'; -import coreModule from 'app/core/core_module'; -import appEvents from 'app/core/app_events'; - -export class DashImportListCtrl { - dashboards: any[]; - plugin: any; - datasource: any; - - /** @ngInject */ - constructor($scope, private backendSrv, private $rootScope) { - this.dashboards = []; - - backendSrv.get(`/api/plugins/${this.plugin.id}/dashboards`).then(dashboards => { - this.dashboards = dashboards; - }); - - appEvents.on('dashboard-list-import-all', this.importAll.bind(this), $scope); - } - - importAll(payload) { - return this.importNext(0) - .then(() => { - payload.resolve('All dashboards imported'); - }) - .catch(err => { - payload.reject(err); - }); - } - - importNext(index) { - return this.import(this.dashboards[index], true).then(() => { - if (index + 1 < this.dashboards.length) { - return new Promise(resolve => { - setTimeout(() => { - this.importNext(index + 1).then(() => { - resolve(); - }); - }, 500); - }); - } else { - return Promise.resolve(); - } - }); - } - - import(dash, overwrite) { - const installCmd = { - pluginId: this.plugin.id, - path: dash.path, - overwrite: overwrite, - inputs: [], - }; - - if (this.datasource) { - installCmd.inputs.push({ - name: '*', - type: 'datasource', - pluginId: this.datasource.type, - value: this.datasource.name, - }); - } - - return this.backendSrv.post(`/api/dashboards/import`, installCmd).then(res => { - this.$rootScope.appEvent('alert-success', ['Dashboard Imported', dash.title]); - _.extend(dash, res); - }); - } - - remove(dash) { - this.backendSrv.delete('/api/dashboards/' + dash.importedUri).then(() => { - this.$rootScope.appEvent('alert-success', ['Dashboard Deleted', dash.title]); - dash.imported = false; - }); - } -} - -export function dashboardImportList() { - return { - restrict: 'E', - templateUrl: 'public/app/features/plugins/import_list/import_list.html', - controller: DashImportListCtrl, - bindToController: true, - controllerAs: 'ctrl', - scope: { - plugin: '=', - datasource: '=', - }, - }; -} - -coreModule.directive('dashboardImportList', dashboardImportList); diff --git a/public/app/features/plugins/partials/plugin_edit.html b/public/app/features/plugins/partials/plugin_edit.html deleted file mode 100644 index 56e0769757e..00000000000 --- a/public/app/features/plugins/partials/plugin_edit.html +++ /dev/null @@ -1,69 +0,0 @@ -
    - - -
    - -
    -
    diff --git a/public/app/features/plugins/partials/update_instructions.html b/public/app/features/plugins/partials/update_instructions.html index 1a3bdeefaaf..b4164d6e65c 100644 --- a/public/app/features/plugins/partials/update_instructions.html +++ b/public/app/features/plugins/partials/update_instructions.html @@ -12,9 +12,9 @@
    diff --git a/public/app/features/plugins/plugin_component.ts b/public/app/features/plugins/plugin_component.ts index 6f6679886dd..ab761f0f379 100644 --- a/public/app/features/plugins/plugin_component.ts +++ b/public/app/features/plugins/plugin_component.ts @@ -147,7 +147,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ name: 'ds-config-' + dsMeta.id, bindings: { meta: '=', current: '=' }, attrs: { meta: 'ctrl.datasourceMeta', current: 'ctrl.current' }, - Component: dsPlugin.components.ConfigCtrl, + Component: dsPlugin.angularConfigCtrl, }; }); } @@ -160,7 +160,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ name: 'app-config-' + model.id, bindings: { appModel: '=', appEditCtrl: '=' }, attrs: { 'app-model': 'ctrl.model', 'app-edit-ctrl': 'ctrl' }, - Component: appPlugin.angular.ConfigCtrl, + Component: appPlugin.angularConfigCtrl, }; }); } @@ -173,7 +173,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ name: 'app-page-' + appModel.id + '-' + scope.ctrl.page.slug, bindings: { appModel: '=' }, attrs: { 'app-model': 'ctrl.appModel' }, - Component: appPlugin.angular.pages[scope.ctrl.page.component], + Component: appPlugin.angularPages[scope.ctrl.page.component], }; }); } diff --git a/public/app/features/plugins/plugin_edit_ctrl.ts b/public/app/features/plugins/plugin_edit_ctrl.ts deleted file mode 100644 index d1967525688..00000000000 --- a/public/app/features/plugins/plugin_edit_ctrl.ts +++ /dev/null @@ -1,180 +0,0 @@ -import angular from 'angular'; -import _ from 'lodash'; -import Remarkable from 'remarkable'; -import { getPluginSettings } from './PluginSettingsCache'; - -export class PluginEditCtrl { - model: any; - pluginIcon: string; - pluginId: any; - includes: any; - readmeHtml: any; - includedDatasources: any; - tab: string; - navModel: any; - hasDashboards: any; - preUpdateHook: () => any; - postUpdateHook: () => any; - - /** @ngInject */ - constructor(private $scope, private $rootScope, private backendSrv, private $sce, private $routeParams, navModelSrv) { - this.pluginId = $routeParams.pluginId; - this.preUpdateHook = () => Promise.resolve(); - this.postUpdateHook = () => Promise.resolve(); - - this.init(); - } - - setNavModel(model) { - let defaultTab = 'readme'; - - this.navModel = { - main: { - img: model.info.logos.large, - subTitle: model.info.author.name, - url: '', - text: model.name, - breadcrumbs: [{ title: 'Plugins', url: 'plugins' }], - children: [ - { - icon: 'fa fa-fw fa-file-text-o', - id: 'readme', - text: 'Readme', - url: `plugins/${this.model.id}/edit?tab=readme`, - }, - ], - }, - }; - - if (model.type === 'app') { - this.navModel.main.children.push({ - icon: 'gicon gicon-cog', - id: 'config', - text: 'Config', - url: `plugins/${this.model.id}/edit?tab=config`, - }); - - const hasDashboards: any = _.find(model.includes, { type: 'dashboard' }); - - if (hasDashboards) { - this.navModel.main.children.push({ - icon: 'gicon gicon-dashboard', - id: 'dashboards', - text: 'Dashboards', - url: `plugins/${this.model.id}/edit?tab=dashboards`, - }); - } - - defaultTab = 'config'; - } - - this.tab = this.$routeParams.tab || defaultTab; - - for (const tab of this.navModel.main.children) { - if (tab.id === this.tab) { - tab.active = true; - } - } - } - - init() { - return getPluginSettings(this.pluginId).then(result => { - this.model = result; - this.pluginIcon = this.getPluginIcon(this.model.type); - - this.model.dependencies.plugins.forEach(plug => { - plug.icon = this.getPluginIcon(plug.type); - }); - - this.includes = _.map(result.includes, plug => { - plug.icon = this.getPluginIcon(plug.type); - return plug; - }); - - this.setNavModel(this.model); - return this.initReadme(); - }); - } - - initReadme() { - return this.backendSrv.get(`/api/plugins/${this.pluginId}/markdown/readme`).then(res => { - const md = new Remarkable({ - linkify: true, - }); - this.readmeHtml = this.$sce.trustAsHtml(md.render(res)); - }); - } - - getPluginIcon(type) { - switch (type) { - case 'datasource': - return 'gicon gicon-datasources'; - case 'panel': - return 'icon-gf icon-gf-panel'; - case 'app': - return 'icon-gf icon-gf-apps'; - case 'page': - return 'icon-gf icon-gf-endpoint-tiny'; - case 'dashboard': - return 'gicon gicon-dashboard'; - default: - return 'icon-gf icon-gf-apps'; - } - } - - update() { - this.preUpdateHook() - .then(() => { - const updateCmd = _.extend( - { - enabled: this.model.enabled, - pinned: this.model.pinned, - jsonData: this.model.jsonData, - secureJsonData: this.model.secureJsonData, - }, - {} - ); - return this.backendSrv.post(`/api/plugins/${this.pluginId}/settings`, updateCmd); - }) - .then(this.postUpdateHook) - .then(res => { - window.location.href = window.location.href; - }); - } - - importDashboards() { - return Promise.resolve(); - } - - setPreUpdateHook(callback: () => any) { - this.preUpdateHook = callback; - } - - setPostUpdateHook(callback: () => any) { - this.postUpdateHook = callback; - } - - updateAvailable() { - const modalScope = this.$scope.$new(true); - modalScope.plugin = this.model; - - this.$rootScope.appEvent('show-modal', { - src: 'public/app/features/plugins/partials/update_instructions.html', - scope: modalScope, - }); - } - - enable() { - this.model.enabled = true; - this.model.pinned = true; - this.update(); - } - - disable() { - this.model.enabled = false; - this.model.pinned = false; - this.update(); - } -} - -angular.module('grafana.controllers').controller('PluginEditCtrl', PluginEditCtrl); diff --git a/public/app/features/plugins/wrappers/AppConfigWrapper.tsx b/public/app/features/plugins/wrappers/AppConfigWrapper.tsx new file mode 100644 index 00000000000..de6c670679d --- /dev/null +++ b/public/app/features/plugins/wrappers/AppConfigWrapper.tsx @@ -0,0 +1,139 @@ +// Libraries +import React, { PureComponent } from 'react'; +import cloneDeep from 'lodash/cloneDeep'; +import extend from 'lodash/extend'; + +import { PluginMeta, AppPlugin, Button } from '@grafana/ui'; + +import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { ButtonVariant } from '@grafana/ui/src/components/Button/AbstractButton'; +import { css } from 'emotion'; + +interface Props { + app: AppPlugin; +} + +interface State { + angularCtrl: AngularComponent; + refresh: number; +} + +export class AppConfigCtrlWrapper extends PureComponent { + element: HTMLElement; // for angular ctrl + + // Needed for angular scope + preUpdateHook = () => Promise.resolve(); + postUpdateHook = () => Promise.resolve(); + model: PluginMeta; + + constructor(props: Props) { + super(props); + this.state = { + angularCtrl: null, + refresh: 0, + }; + } + + componentDidMount() { + // Force a reload after the first mount -- is there a better way to do this? + setTimeout(() => { + this.setState({ refresh: this.state.refresh + 1 }); + }, 5); + } + + componentDidUpdate(prevProps: Props) { + if (!this.element || this.state.angularCtrl) { + return; + } + + // Set a copy of the meta + this.model = cloneDeep(this.props.app.meta); + + const loader = getAngularLoader(); + const template = ''; + const scopeProps = { ctrl: this }; + const angularCtrl = loader.load(this.element, scopeProps, template); + + this.setState({ angularCtrl }); + } + + render() { + const model = this.model; + + const withRightMargin = css({ marginRight: '8px' }); + + return ( +
    +
    (this.element = element)} /> +
    +
    + {model && ( +
    + {!model.enabled && ( + + )} + {model.enabled && ( + + )} + {model.enabled && ( + + )} +
    + )} +
    + ); + } + + //----------------------------------------------------------- + // Copied from plugin_edit_ctrl + //----------------------------------------------------------- + + update = () => { + const pluginId = this.model.id; + + this.preUpdateHook() + .then(() => { + const updateCmd = extend( + { + enabled: this.model.enabled, + pinned: this.model.pinned, + jsonData: this.model.jsonData, + secureJsonData: this.model.secureJsonData, + }, + {} + ); + return getBackendSrv().post(`/api/plugins/${pluginId}/settings`, updateCmd); + }) + .then(this.postUpdateHook) + .then(res => { + window.location.href = window.location.href; + }); + }; + + setPreUpdateHook = (callback: () => any) => { + this.preUpdateHook = callback; + }; + + setPostUpdateHook = (callback: () => any) => { + this.postUpdateHook = callback; + }; + + enable = () => { + this.model.enabled = true; + this.model.pinned = true; + this.update(); + }; + + disable = () => { + this.model.enabled = false; + this.model.pinned = false; + this.update(); + }; +} diff --git a/public/app/plugins/app/example-app/ExampleRootPage.tsx b/public/app/plugins/app/example-app/ExampleRootPage.tsx new file mode 100644 index 00000000000..488d3b511a1 --- /dev/null +++ b/public/app/plugins/app/example-app/ExampleRootPage.tsx @@ -0,0 +1,102 @@ +// Libraries +import React, { PureComponent } from 'react'; + +// Types +import { AppRootProps, NavModelItem } from '@grafana/ui'; + +interface Props extends AppRootProps {} + +const TAB_ID_A = 'A'; +const TAB_ID_B = 'B'; +const TAB_ID_C = 'C'; + +export class ExampleRootPage extends PureComponent { + constructor(props: Props) { + super(props); + } + + componentDidMount() { + this.updateNav(); + } + + componentDidUpdate(prevProps: Props) { + if (this.props.query !== prevProps.query) { + if (this.props.query.tab !== prevProps.query.tab) { + this.updateNav(); + } + } + } + + updateNav() { + const { path, onNavChanged, query, meta } = this.props; + + const tabs: NavModelItem[] = []; + tabs.push({ + text: 'Tab A', + icon: 'fa fa-fw fa-file-text-o', + url: path + '?tab=' + TAB_ID_A, + id: TAB_ID_A, + }); + tabs.push({ + text: 'Tab B', + icon: 'fa fa-fw fa-file-text-o', + url: path + '?tab=' + TAB_ID_B, + id: TAB_ID_B, + }); + tabs.push({ + text: 'Tab C', + icon: 'fa fa-fw fa-file-text-o', + url: path + '?tab=' + TAB_ID_C, + id: TAB_ID_C, + }); + + // Set the active tab + let found = false; + const selected = query.tab || TAB_ID_B; + for (const tab of tabs) { + tab.active = !found && selected === tab.id; + if (tab.active) { + found = true; + } + } + if (!found) { + tabs[0].active = true; + } + + const node = { + text: 'This is the Page title', + img: meta.info.logos.large, + subTitle: 'subtitle here', + url: path, + children: tabs, + }; + + // Update the page header + onNavChanged({ + node: node, + main: node, + }); + } + + render() { + const { path, query } = this.props; + + return ( +
    + QUERY:
    {JSON.stringify(query)}
    +
    + +
    + ); + } +} diff --git a/public/app/plugins/app/example-app/config/ExampleTab1.tsx b/public/app/plugins/app/example-app/config/ExampleTab1.tsx new file mode 100644 index 00000000000..cf79a880f43 --- /dev/null +++ b/public/app/plugins/app/example-app/config/ExampleTab1.tsx @@ -0,0 +1,25 @@ +// Libraries +import React, { PureComponent } from 'react'; + +// Types +import { PluginConfigTabProps, AppPluginMeta } from '@grafana/ui'; + +interface Props extends PluginConfigTabProps {} + +export class ExampleTab1 extends PureComponent { + constructor(props: Props) { + super(props); + } + + render() { + const { query } = this.props; + + return ( +
    + 11111111111111111111111111111111 +
    {JSON.stringify(query)}
    + 11111111111111111111111111111111 +
    + ); + } +} diff --git a/public/app/plugins/app/example-app/config/ExampleTab2.tsx b/public/app/plugins/app/example-app/config/ExampleTab2.tsx new file mode 100644 index 00000000000..bf2ae181405 --- /dev/null +++ b/public/app/plugins/app/example-app/config/ExampleTab2.tsx @@ -0,0 +1,25 @@ +// Libraries +import React, { PureComponent } from 'react'; + +// Types +import { PluginConfigTabProps, AppPluginMeta } from '@grafana/ui'; + +interface Props extends PluginConfigTabProps {} + +export class ExampleTab2 extends PureComponent { + constructor(props: Props) { + super(props); + } + + render() { + const { query } = this.props; + + return ( +
    + 22222222222222222222222222222222 +
    {JSON.stringify(query)}
    + 22222222222222222222222222222222 +
    + ); + } +} diff --git a/public/app/plugins/app/example-app/dashboards/stats.json b/public/app/plugins/app/example-app/dashboards/stats.json new file mode 100644 index 00000000000..45e2316e4de --- /dev/null +++ b/public/app/plugins/app/example-app/dashboards/stats.json @@ -0,0 +1,110 @@ +{ + "__inputs": [], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "6.2.0-pre" + }, + { + "type": "panel", + "id": "singlestat2", + "name": "Singlestat (react)", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "orientation": "auto", + "sparkline": { + "fillColor": "rgba(31, 118, 189, 0.18)", + "full": false, + "lineColor": "rgb(31, 120, 193)", + "show": true + }, + "thresholds": [ + { + "color": "green", + "index": 0, + "value": null + }, + { + "color": "red", + "index": 1, + "value": 80 + } + ], + "valueMappings": [], + "valueOptions": { + "decimals": null, + "prefix": "", + "stat": "mean", + "suffix": "", + "unit": "none" + } + }, + "pluginVersion": "6.2.0-pre", + "targets": [ + { + "refId": "A", + "scenarioId": "random_walk_table", + "stringInput": "" + }, + { + "refId": "B", + "scenarioId": "random_walk_table", + "stringInput": "" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Panel Title", + "type": "singlestat2" + } + ], + "schemaVersion": 18, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, + "timezone": "", + "title": "stats", + "uid": "YeBxHjzWz", + "version": 1 +} diff --git a/public/app/plugins/app/example-app/dashboards/streaming.json b/public/app/plugins/app/example-app/dashboards/streaming.json new file mode 100644 index 00000000000..ec6714f8816 --- /dev/null +++ b/public/app/plugins/app/example-app/dashboards/streaming.json @@ -0,0 +1,83 @@ +{ + "__inputs": [], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "6.2.0-pre" + }, + { + "type": "panel", + "id": "graph2", + "name": "React Graph", + "version": "" + } + ], + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "description": "", + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 2, + "links": [], + "targets": [ + { + "refId": "A", + "scenarioId": "streaming_client", + "stream": { + "noise": 10, + "speed": 100, + "spread": 20, + "type": "signal" + }, + "stringInput": "" + } + ], + "timeFrom": null, + "timeShift": null, + "title": "Simple dummy streaming example", + "type": "graph2" + } + ], + "schemaVersion": 18, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-1m", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"], + "time_options": ["5m", "15m", "1h", "6h", "12h", "24h", "2d", "7d", "30d"] + }, + "timezone": "", + "title": "simple streaming", + "uid": "TbbEZjzWz", + "version": 1 +} diff --git a/public/app/plugins/app/example-app/module.ts b/public/app/plugins/app/example-app/module.ts index 1e2a393d17a..0b4a2ae646a 100644 --- a/public/app/plugins/app/example-app/module.ts +++ b/public/app/plugins/app/example-app/module.ts @@ -1,9 +1,28 @@ // Angular pages import { ExampleConfigCtrl } from './legacy/config'; import { AngularExamplePageCtrl } from './legacy/angular_example_page'; +import { AppPlugin } from '@grafana/ui'; +import { ExampleTab1 } from './config/ExampleTab1'; +import { ExampleTab2 } from './config/ExampleTab2'; +import { ExampleRootPage } from './ExampleRootPage'; +// Legacy exports just for testing export { ExampleConfigCtrl as ConfigCtrl, - // Must match `pages.component` in plugin.json - AngularExamplePageCtrl, + AngularExamplePageCtrl, // Must match `pages.component` in plugin.json }; + +export const plugin = new AppPlugin() + .setRootPage(ExampleRootPage) + .addConfigTab({ + title: 'Tab 1', + icon: 'fa fa-info', + body: ExampleTab1, + id: 'tab1', + }) + .addConfigTab({ + title: 'Tab 2', + icon: 'fa fa-user', + body: ExampleTab2, + id: 'tab2', + }); diff --git a/public/app/plugins/app/example-app/plugin.json b/public/app/plugins/app/example-app/plugin.json index 8864468768f..e514d4c3296 100644 --- a/public/app/plugins/app/example-app/plugin.json +++ b/public/app/plugins/app/example-app/plugin.json @@ -23,6 +23,20 @@ "role": "Viewer", "addToNav": true, "defaultNav": true + }, + { + "type": "dashboard", + "name": "Streaming Example", + "path": "dashboards/streaming.json" + }, + { + "type": "dashboard", + "name": "Lots of Stats", + "path": "dashboards/stats.json" + }, + { + "type": "panel", + "name": "Anything -- just display?" } ] } diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index 19bb96be603..9a7c9b5d2d5 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -22,6 +22,8 @@ import DataSourceSettingsPage from '../features/datasources/settings/DataSourceS import OrgDetailsPage from '../features/org/OrgDetailsPage'; import SoloPanelPage from '../features/dashboard/containers/SoloPanelPage'; import DashboardPage from '../features/dashboard/containers/DashboardPage'; +import PluginPage from '../features/plugins/PluginPage'; +import AppRootPage from 'app/features/plugins/AppRootPage'; import config from 'app/core/config'; // Types @@ -164,6 +166,14 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { component: () => import(/* webpackChunkName: "explore" */ 'app/features/explore/Wrapper'), }, }) + .when('/a/:pluginId/', { + // Someday * and will get a ReactRouter under that path! + template: '', + reloadOnSearch: false, + resolve: { + component: () => AppRootPage, + }, + }) .when('/org', { template: '', resolve: { @@ -301,10 +311,12 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { component: () => PluginListPage, }, }) - .when('/plugins/:pluginId/edit', { - templateUrl: 'public/app/features/plugins/partials/plugin_edit.html', - controller: 'PluginEditCtrl', - controllerAs: 'ctrl', + .when('/plugins/:pluginId/', { + template: '', + reloadOnSearch: false, // tabs from query parameters + resolve: { + component: () => PluginPage, + }, }) .when('/plugins/:pluginId/page/:slug', { templateUrl: 'public/app/features/plugins/partials/plugin_page.html',
    +

    Version

    + {meta.info.version} + {meta.hasUpdate && ( +
    + )} +