ソースを参照

DataSourcePlugin: support custom tabs (#16859)

* use ConfigEditor

* add tabs

* add tabs

* set the nav in state

* remove actions

* reorder imports

* catch plugin loading errors

* better text

* keep props

* fix typo

* update snapshot

* rename tab to page

* add missing pages
Ryan McKinley 6 年 前
コミット
a87a763d83

+ 9 - 9
packages/grafana-ui/src/types/plugin.ts

@@ -91,17 +91,17 @@ export interface PluginMetaInfo {
   version: string;
 }
 
-export interface PluginConfigTabProps<T extends PluginMeta> {
-  meta: T;
+export interface PluginConfigPageProps<T extends GrafanaPlugin> {
+  plugin: T;
   query: { [s: string]: any }; // The URL query parameters
 }
 
-export interface PluginConfigTab<T extends PluginMeta> {
+export interface PluginConfigPage<T extends GrafanaPlugin> {
   title: string; // Display
   icon?: string;
   id: string; // Unique, in URL
 
-  body: ComponentClass<PluginConfigTabProps<T>>;
+  body: ComponentClass<PluginConfigPageProps<T>>;
 }
 
 export class GrafanaPlugin<T extends PluginMeta = PluginMeta> {
@@ -112,14 +112,14 @@ export class GrafanaPlugin<T extends PluginMeta = PluginMeta> {
   angularConfigCtrl?: any;
 
   // Show configuration tabs on the plugin page
-  configTabs?: Array<PluginConfigTab<T>>;
+  configPages?: Array<PluginConfigPage<GrafanaPlugin>>;
 
   // Tabs on the plugin page
-  addConfigTab(tab: PluginConfigTab<T>) {
-    if (!this.configTabs) {
-      this.configTabs = [];
+  addConfigPage(tab: PluginConfigPage<GrafanaPlugin>) {
+    if (!this.configPages) {
+      this.configPages = [];
     }
-    this.configTabs.push(tab);
+    this.configPages.push(tab);
     return this;
   }
 }

+ 1 - 2
public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx

@@ -19,7 +19,7 @@ const setup = (propOverrides?: object) => {
     setDataSourceName,
     updateDataSource: jest.fn(),
     setIsDefault,
-    plugin: pluginMock,
+    query: {},
     ...propOverrides,
   };
 
@@ -45,7 +45,6 @@ describe('Render', () => {
   it('should render beta info text', () => {
     const wrapper = setup({
       dataSourceMeta: { ...getMockPlugin(), state: 'beta' },
-      plugin: pluginMock,
     });
 
     expect(wrapper).toMatchSnapshot();

+ 147 - 64
public/app/features/datasources/settings/DataSourceSettingsPage.tsx

@@ -2,6 +2,7 @@
 import React, { PureComponent } from 'react';
 import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
+import isString from 'lodash/isString';
 
 // Components
 import Page from 'app/core/components/Page/Page';
@@ -21,7 +22,7 @@ import { getNavModel } from 'app/core/selectors/navModel';
 import { getRouteParamsId } from 'app/core/selectors/location';
 
 // Types
-import { StoreState } from 'app/types/';
+import { StoreState, UrlQueryMap } from 'app/types/';
 import { NavModel, DataSourceSettings, DataSourcePluginMeta } from '@grafana/ui';
 import { getDataSourceLoadingNav } from '../state/navModel';
 import PluginStateinfo from 'app/features/plugins/PluginStateInfo';
@@ -38,14 +39,17 @@ export interface Props {
   updateDataSource: typeof updateDataSource;
   setIsDefault: typeof setIsDefault;
   plugin?: GenericDataSourcePlugin;
+  query: UrlQueryMap;
+  page?: string;
 }
 
 interface State {
   dataSource: DataSourceSettings;
-  plugin: GenericDataSourcePlugin;
+  plugin?: GenericDataSourcePlugin;
   isTesting?: boolean;
   testingMessage?: string;
   testingStatus?: string;
+  loadError?: any;
 }
 
 export class DataSourceSettingsPage extends PureComponent<Props, State> {
@@ -73,9 +77,17 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
 
   async componentDidMount() {
     const { loadDataSource, pageId } = this.props;
-    await loadDataSource(pageId);
-    if (!this.state.plugin) {
-      await this.loadPlugin();
+    if (isNaN(pageId)) {
+      this.setState({ loadError: 'Invalid ID' });
+      return;
+    }
+    try {
+      await loadDataSource(pageId);
+      if (!this.state.plugin) {
+        await this.loadPlugin();
+      }
+    } catch (err) {
+      this.setState({ loadError: err });
     }
   }
 
@@ -174,70 +186,133 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
     return this.state.dataSource.id > 0;
   }
 
-  render() {
-    const { dataSourceMeta, navModel, setDataSourceName, setIsDefault } = this.props;
-    const { testingMessage, testingStatus, plugin, dataSource } = this.state;
+  renderLoadError(loadError: any) {
+    let showDelete = false;
+    let msg = loadError.toString();
+    if (loadError.data) {
+      if (loadError.data.message) {
+        msg = loadError.data.message;
+      }
+    } else if (isString(loadError)) {
+      showDelete = true;
+    }
+
+    const node = {
+      text: msg,
+      subTitle: 'Data Source Error',
+      icon: 'fa fa-fw fa-warning',
+    };
+    const nav = {
+      node: node,
+      main: node,
+    };
 
     return (
-      <Page navModel={navModel}>
-        <Page.Contents isLoading={!this.hasDataSource}>
-          {this.hasDataSource && (
-            <div>
-              <form onSubmit={this.onSubmit}>
-                {this.isReadOnly() && this.renderIsReadOnlyMessage()}
-                {dataSourceMeta.state && (
-                  <div className="gf-form">
-                    <label className="gf-form-label width-10">Plugin state</label>
-                    <label className="gf-form-label gf-form-label--transparent">
-                      <PluginStateinfo state={dataSourceMeta.state} />
-                    </label>
-                  </div>
-                )}
+      <Page navModel={nav}>
+        <Page.Contents>
+          <div>
+            <div className="gf-form-button-row">
+              {showDelete && (
+                <button type="submit" className="btn btn-danger" onClick={this.onDelete}>
+                  Delete
+                </button>
+              )}
+              <a className="btn btn-inverse" href="datasources">
+                Back
+              </a>
+            </div>
+          </div>
+        </Page.Contents>
+      </Page>
+    );
+  }
 
-                <BasicSettings
-                  dataSourceName={dataSource.name}
-                  isDefault={dataSource.isDefault}
-                  onDefaultChange={state => setIsDefault(state)}
-                  onNameChange={name => setDataSourceName(name)}
-                />
-
-                {dataSourceMeta.module && plugin && (
-                  <PluginSettings
-                    plugin={plugin}
-                    dataSource={this.state.dataSource}
-                    dataSourceMeta={dataSourceMeta}
-                    onModelChange={this.onModelChange}
-                  />
-                )}
+  renderConfigPageBody(page: string) {
+    const { plugin } = this.state;
+    if (!plugin || !plugin.configPages) {
+      return null; // still loading
+    }
+
+    for (const p of plugin.configPages) {
+      if (p.id === page) {
+        return <p.body plugin={plugin} query={this.props.query} />;
+      }
+    }
+
+    return <div>Page Not Found: {page}</div>;
+  }
+
+  renderSettings() {
+    const { dataSourceMeta, setDataSourceName, setIsDefault } = this.props;
+    const { testingMessage, testingStatus, dataSource, plugin } = this.state;
 
-                <div className="gf-form-group">
-                  {testingMessage && (
-                    <div className={`alert-${testingStatus} alert`} aria-label="Datasource settings page Alert">
-                      <div className="alert-icon">
-                        {testingStatus === 'error' ? (
-                          <i className="fa fa-exclamation-triangle" />
-                        ) : (
-                          <i className="fa fa-check" />
-                        )}
-                      </div>
-                      <div className="alert-body">
-                        <div className="alert-title" aria-label="Datasource settings page Alert message">
-                          {testingMessage}
-                        </div>
-                      </div>
-                    </div>
-                  )}
-                </div>
-
-                <ButtonRow
-                  onSubmit={event => this.onSubmit(event)}
-                  isReadOnly={this.isReadOnly()}
-                  onDelete={this.onDelete}
-                  onTest={event => this.onTest(event)}
-                />
-              </form>
+    return (
+      <form onSubmit={this.onSubmit}>
+        {this.isReadOnly() && this.renderIsReadOnlyMessage()}
+        {dataSourceMeta.state && (
+          <div className="gf-form">
+            <label className="gf-form-label width-10">Plugin state</label>
+            <label className="gf-form-label gf-form-label--transparent">
+              <PluginStateinfo state={dataSourceMeta.state} />
+            </label>
+          </div>
+        )}
+
+        <BasicSettings
+          dataSourceName={dataSource.name}
+          isDefault={dataSource.isDefault}
+          onDefaultChange={state => setIsDefault(state)}
+          onNameChange={name => setDataSourceName(name)}
+        />
+
+        {plugin && (
+          <PluginSettings
+            plugin={plugin}
+            dataSource={this.state.dataSource}
+            dataSourceMeta={dataSourceMeta}
+            onModelChange={this.onModelChange}
+          />
+        )}
+
+        <div className="gf-form-group">
+          {testingMessage && (
+            <div className={`alert-${testingStatus} alert`}>
+              <div className="alert-icon">
+                {testingStatus === 'error' ? (
+                  <i className="fa fa-exclamation-triangle" />
+                ) : (
+                  <i className="fa fa-check" />
+                )}
+              </div>
+              <div className="alert-body">
+                <div className="alert-title">{testingMessage}</div>
+              </div>
             </div>
           )}
+        </div>
+
+        <ButtonRow
+          onSubmit={event => this.onSubmit(event)}
+          isReadOnly={this.isReadOnly()}
+          onDelete={this.onDelete}
+          onTest={event => this.onTest(event)}
+        />
+      </form>
+    );
+  }
+
+  render() {
+    const { navModel, page } = this.props;
+    const { loadError } = this.state;
+
+    if (loadError) {
+      return this.renderLoadError(loadError);
+    }
+
+    return (
+      <Page navModel={navModel}>
+        <Page.Contents isLoading={!this.hasDataSource}>
+          {this.hasDataSource && <div>{page ? this.renderConfigPageBody(page) : this.renderSettings()}</div>}
         </Page.Contents>
       </Page>
     );
@@ -247,11 +322,19 @@ export class DataSourceSettingsPage extends PureComponent<Props, State> {
 function mapStateToProps(state: StoreState) {
   const pageId = getRouteParamsId(state.location);
   const dataSource = getDataSource(state.dataSources, pageId);
+  const page = state.location.query.page as string;
+
   return {
-    navModel: getNavModel(state.navIndex, `datasource-settings-${pageId}`, getDataSourceLoadingNav('settings')),
+    navModel: getNavModel(
+      state.navIndex,
+      page ? `datasource-page-${page}` : `datasource-settings-${pageId}`,
+      getDataSourceLoadingNav('settings')
+    ),
     dataSource: getDataSource(state.dataSources, pageId),
     dataSourceMeta: getDataSourceMeta(state.dataSources, dataSource.type),
     pageId: pageId,
+    query: state.location.query,
+    page,
   };
 }
 

+ 1 - 1
public/app/features/datasources/settings/PluginSettings.tsx

@@ -54,7 +54,7 @@ export class PluginSettings extends PureComponent<Props> {
     }
   }
 
-  componentDidUpdate(prevProps) {
+  componentDidUpdate(prevProps: Props) {
     const { plugin } = this.props;
     if (!plugin.components.ConfigEditor && this.props.dataSource !== prevProps.dataSource) {
       this.scopeProps.ctrl.current = _.cloneDeep(this.props.dataSource);

+ 0 - 143
public/app/features/datasources/settings/__snapshots__/DataSourceSettingsPage.test.tsx.snap

@@ -153,78 +153,6 @@ exports[`Render should render beta info text 1`] = `
           onDefaultChange={[Function]}
           onNameChange={[Function]}
         />
-        <PluginSettings
-          dataSource={
-            Object {
-              "access": "",
-              "basicAuth": false,
-              "basicAuthPassword": "",
-              "basicAuthUser": "",
-              "database": "",
-              "id": 13,
-              "isDefault": false,
-              "jsonData": Object {
-                "authType": "credentials",
-                "defaultRegion": "eu-west-2",
-              },
-              "name": "gdev-cloudwatch",
-              "orgId": 1,
-              "password": "",
-              "readOnly": false,
-              "type": "cloudwatch",
-              "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
-              "url": "",
-              "user": "",
-              "withCredentials": false,
-            }
-          }
-          dataSourceMeta={
-            Object {
-              "baseUrl": "path/to/plugin",
-              "defaultNavUrl": "some/url",
-              "enabled": false,
-              "hasUpdate": false,
-              "id": "1",
-              "info": Object {
-                "author": Object {
-                  "name": "Grafana Labs",
-                  "url": "url/to/GrafanaLabs",
-                },
-                "description": "pretty decent plugin",
-                "links": Array [
-                  Object {
-                    "name": "project",
-                    "url": "one link",
-                  },
-                ],
-                "logos": Object {
-                  "large": "large/logo",
-                  "small": "small/logo",
-                },
-                "screenshots": Array [
-                  Object {
-                    "path": "screenshot",
-                  },
-                ],
-                "updated": "2018-09-26",
-                "version": "1",
-              },
-              "latestVersion": "1",
-              "module": "path/to/module",
-              "name": "pretty cool plugin 1",
-              "pinned": false,
-              "state": "beta",
-              "type": "panel",
-            }
-          }
-          onModelChange={[Function]}
-          plugin={
-            DataSourcePlugin {
-              "DataSourceClass": Object {},
-              "components": Object {},
-            }
-          }
-        />
         <div
           className="gf-form-group"
         />
@@ -257,77 +185,6 @@ exports[`Render should render component 1`] = `
           onDefaultChange={[Function]}
           onNameChange={[Function]}
         />
-        <PluginSettings
-          dataSource={
-            Object {
-              "access": "",
-              "basicAuth": false,
-              "basicAuthPassword": "",
-              "basicAuthUser": "",
-              "database": "",
-              "id": 13,
-              "isDefault": false,
-              "jsonData": Object {
-                "authType": "credentials",
-                "defaultRegion": "eu-west-2",
-              },
-              "name": "gdev-cloudwatch",
-              "orgId": 1,
-              "password": "",
-              "readOnly": false,
-              "type": "cloudwatch",
-              "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
-              "url": "",
-              "user": "",
-              "withCredentials": false,
-            }
-          }
-          dataSourceMeta={
-            Object {
-              "baseUrl": "path/to/plugin",
-              "defaultNavUrl": "some/url",
-              "enabled": false,
-              "hasUpdate": false,
-              "id": "1",
-              "info": Object {
-                "author": Object {
-                  "name": "Grafana Labs",
-                  "url": "url/to/GrafanaLabs",
-                },
-                "description": "pretty decent plugin",
-                "links": Array [
-                  Object {
-                    "name": "project",
-                    "url": "one link",
-                  },
-                ],
-                "logos": Object {
-                  "large": "large/logo",
-                  "small": "small/logo",
-                },
-                "screenshots": Array [
-                  Object {
-                    "path": "screenshot",
-                  },
-                ],
-                "updated": "2018-09-26",
-                "version": "1",
-              },
-              "latestVersion": "1",
-              "module": "path/to/module",
-              "name": "pretty cool plugin 1",
-              "pinned": false,
-              "type": "panel",
-            }
-          }
-          onModelChange={[Function]}
-          plugin={
-            DataSourcePlugin {
-              "DataSourceClass": Object {},
-              "components": Object {},
-            }
-          }
-        />
         <div
           className="gf-form-group"
         />

+ 4 - 1
public/app/features/datasources/state/actions.ts

@@ -10,6 +10,7 @@ import { StoreState, LocationUpdate } from 'app/types';
 import { actionCreatorFactory } from 'app/core/redux';
 import { ActionOf, noPayloadActionCreatorFactory } from 'app/core/redux/actionCreatorFactory';
 import { getPluginSettings } from 'app/features/plugins/PluginSettingsCache';
+import { importDataSourcePlugin } from 'app/features/plugins/plugin_loader';
 
 export const dataSourceLoaded = actionCreatorFactory<DataSourceSettings>('LOAD_DATA_SOURCE').create();
 
@@ -52,9 +53,11 @@ export function loadDataSource(id: number): ThunkResult<void> {
   return async dispatch => {
     const dataSource = await getBackendSrv().get(`/api/datasources/${id}`);
     const pluginInfo = (await getPluginSettings(dataSource.type)) as DataSourcePluginMeta;
+    const plugin = await importDataSourcePlugin(pluginInfo);
+
     dispatch(dataSourceLoaded(dataSource));
     dispatch(dataSourceMetaLoaded(pluginInfo));
-    dispatch(updateNavIndex(buildNavModel(dataSource, pluginInfo)));
+    dispatch(updateNavIndex(buildNavModel(dataSource, plugin)));
   };
 }
 

+ 39 - 22
public/app/features/datasources/state/navModel.ts

@@ -1,7 +1,10 @@
-import { PluginMeta, DataSourceSettings, PluginType, NavModel, NavModelItem, PluginInclude } from '@grafana/ui';
+import { DataSourceSettings, PluginType, NavModel, NavModelItem, PluginInclude } from '@grafana/ui';
 import config from 'app/core/config';
+import { GenericDataSourcePlugin } from '../settings/PluginSettings';
+
+export function buildNavModel(dataSource: DataSourceSettings, plugin: GenericDataSourcePlugin): NavModelItem {
+  const pluginMeta = plugin.meta;
 
-export function buildNavModel(dataSource: DataSourceSettings, pluginMeta: PluginMeta): NavModelItem {
   const navModel = {
     img: pluginMeta.info.logos.large,
     id: 'datasource-' + dataSource.id,
@@ -20,6 +23,18 @@ export function buildNavModel(dataSource: DataSourceSettings, pluginMeta: Plugin
     ],
   };
 
+  if (plugin.configPages) {
+    for (const page of plugin.configPages) {
+      navModel.children.push({
+        active: false,
+        text: page.title,
+        icon: page.icon,
+        url: `datasources/edit/${dataSource.id}/?page=${page.id}`,
+        id: `datasource-page-${page.id}`,
+      });
+    }
+  }
+
   if (pluginMeta.includes && hasDashboards(pluginMeta.includes)) {
     navModel.children.push({
       active: false,
@@ -65,28 +80,30 @@ export function getDataSourceLoadingNav(pageName: string): NavModel {
       user: '',
     },
     {
-      id: '1',
-      type: PluginType.datasource,
-      name: '',
-      info: {
-        author: {
-          name: '',
-          url: '',
-        },
-        description: '',
-        links: [{ name: '', url: '' }],
-        logos: {
-          large: '',
-          small: '',
+      meta: {
+        id: '1',
+        type: PluginType.datasource,
+        name: '',
+        info: {
+          author: {
+            name: '',
+            url: '',
+          },
+          description: '',
+          links: [{ name: '', url: '' }],
+          logos: {
+            large: '',
+            small: '',
+          },
+          screenshots: [],
+          updated: '',
+          version: '',
         },
-        screenshots: [],
-        updated: '',
-        version: '',
+        includes: [],
+        module: '',
+        baseUrl: '',
       },
-      includes: [],
-      module: '',
-      baseUrl: '',
-    }
+    } as GenericDataSourcePlugin
   );
 
   let node: NavModelItem;

+ 46 - 46
public/app/features/plugins/PluginPage.tsx

@@ -74,12 +74,12 @@ interface State {
   loading: boolean;
   plugin?: GrafanaPlugin;
   nav: NavModel;
-  defaultTab: string; // The first configured one or readme
+  defaultPage: string; // The first configured one or readme
 }
 
-const TAB_ID_README = 'readme';
-const TAB_ID_DASHBOARDS = 'dashboards';
-const TAB_ID_CONFIG_CTRL = 'config';
+const PAGE_ID_README = 'readme';
+const PAGE_ID_DASHBOARDS = 'dashboards';
+const PAGE_ID_CONFIG_CTRL = 'config';
 
 class PluginPage extends PureComponent<Props, State> {
   constructor(props: Props) {
@@ -87,7 +87,7 @@ class PluginPage extends PureComponent<Props, State> {
     this.state = {
       loading: true,
       nav: getLoadingNav(),
-      defaultTab: TAB_ID_README,
+      defaultPage: PAGE_ID_README,
     };
   }
 
@@ -103,14 +103,14 @@ class PluginPage extends PureComponent<Props, State> {
     }
     const { meta } = plugin;
 
-    let defaultTab: string;
-    const tabs: NavModelItem[] = [];
+    let defaultPage: string;
+    const pages: NavModelItem[] = [];
     if (true) {
-      tabs.push({
+      pages.push({
         text: 'Readme',
         icon: 'fa fa-fw fa-file-text-o',
-        url: path + '?tab=' + TAB_ID_README,
-        id: TAB_ID_README,
+        url: path + '?page=' + PAGE_ID_README,
+        id: PAGE_ID_README,
       });
     }
 
@@ -118,42 +118,42 @@ class PluginPage extends PureComponent<Props, State> {
     if (meta.type === PluginType.app) {
       // Legacy App Config
       if (plugin.angularConfigCtrl) {
-        tabs.push({
+        pages.push({
           text: 'Config',
           icon: 'gicon gicon-cog',
-          url: path + '?tab=' + TAB_ID_CONFIG_CTRL,
-          id: TAB_ID_CONFIG_CTRL,
+          url: path + '?page=' + PAGE_ID_CONFIG_CTRL,
+          id: PAGE_ID_CONFIG_CTRL,
         });
-        defaultTab = TAB_ID_CONFIG_CTRL;
+        defaultPage = PAGE_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 (plugin.configPages) {
+        for (const page of plugin.configPages) {
+          pages.push({
+            text: page.title,
+            icon: page.icon,
+            url: path + '?page=' + page.id,
+            id: page.id,
           });
-          if (!defaultTab) {
-            defaultTab = tab.id;
+          if (!defaultPage) {
+            defaultPage = page.id;
           }
         }
       }
 
-      // Check for the dashboard tabs
+      // Check for the dashboard pages
       if (find(meta.includes, { type: 'dashboard' })) {
-        tabs.push({
+        pages.push({
           text: 'Dashboards',
           icon: 'gicon gicon-dashboard',
-          url: path + '?tab=' + TAB_ID_DASHBOARDS,
-          id: TAB_ID_DASHBOARDS,
+          url: path + '?page=' + PAGE_ID_DASHBOARDS,
+          id: PAGE_ID_DASHBOARDS,
         });
       }
     }
 
-    if (!defaultTab) {
-      defaultTab = tabs[0].id; // the first tab
+    if (!defaultPage) {
+      defaultPage = pages[0].id; // the first tab
     }
 
     const node = {
@@ -162,13 +162,13 @@ class PluginPage extends PureComponent<Props, State> {
       subTitle: meta.info.author.name,
       breadcrumbs: [{ title: 'Plugins', url: '/plugins' }],
       url: path,
-      children: this.setActiveTab(query.tab as string, tabs, defaultTab),
+      children: this.setActivePage(query.page as string, pages, defaultPage),
     };
 
     this.setState({
       loading: false,
       plugin,
-      defaultTab,
+      defaultPage,
       nav: {
         node: node,
         main: node,
@@ -176,15 +176,15 @@ class PluginPage extends PureComponent<Props, State> {
     });
   }
 
-  setActiveTab(tabId: string, tabs: NavModelItem[], defaultTabId: string): NavModelItem[] {
+  setActivePage(pageId: string, pages: NavModelItem[], defaultPageId: string): NavModelItem[] {
     let found = false;
-    const selected = tabId || defaultTabId;
-    const changed = tabs.map(tab => {
-      const active = !found && selected === tab.id;
+    const selected = pageId || defaultPageId;
+    const changed = pages.map(p => {
+      const active = !found && selected === p.id;
       if (active) {
         found = true;
       }
-      return { ...tab, active };
+      return { ...p, active };
     });
     if (!found) {
       changed[0].active = true;
@@ -193,13 +193,13 @@ class PluginPage extends PureComponent<Props, State> {
   }
 
   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 prevPage = prevProps.query.page as string;
+    const page = this.props.query.page as string;
+    if (prevPage !== page) {
+      const { nav, defaultPage } = this.state;
       const node = {
         ...nav.node,
-        children: this.setActiveTab(tab, nav.node.children, defaultTab),
+        children: this.setActivePage(page, nav.node.children, defaultPage),
       };
       this.setState({
         nav: {
@@ -221,21 +221,21 @@ class PluginPage extends PureComponent<Props, State> {
     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 (plugin.configPages) {
+        for (const tab of plugin.configPages) {
           if (tab.id === active.id) {
-            return <tab.body meta={plugin.meta} query={query} />;
+            return <tab.body plugin={plugin} query={query} />;
           }
         }
       }
 
       // Apps have some special behavior
       if (plugin.meta.type === PluginType.app) {
-        if (active.id === TAB_ID_DASHBOARDS) {
+        if (active.id === PAGE_ID_DASHBOARDS) {
           return <PluginDashboards plugin={plugin.meta} />;
         }
 
-        if (active.id === TAB_ID_CONFIG_CTRL && plugin.angularConfigCtrl) {
+        if (active.id === PAGE_ID_CONFIG_CTRL && plugin.angularConfigCtrl) {
           return <AppConfigCtrlWrapper app={plugin as AppPlugin} />;
         }
       }

+ 3 - 3
public/app/plugins/app/example-app/config/ExampleTab1.tsx → public/app/plugins/app/example-app/config/ExamplePage1.tsx

@@ -2,11 +2,11 @@
 import React, { PureComponent } from 'react';
 
 // Types
-import { PluginConfigTabProps, AppPluginMeta } from '@grafana/ui';
+import { PluginConfigPageProps, AppPlugin } from '@grafana/ui';
 
-interface Props extends PluginConfigTabProps<AppPluginMeta> {}
+interface Props extends PluginConfigPageProps<AppPlugin> {}
 
-export class ExampleTab1 extends PureComponent<Props> {
+export class ExamplePage1 extends PureComponent<Props> {
   constructor(props: Props) {
     super(props);
   }

+ 3 - 3
public/app/plugins/app/example-app/config/ExampleTab2.tsx → public/app/plugins/app/example-app/config/ExamplePage2.tsx

@@ -2,11 +2,11 @@
 import React, { PureComponent } from 'react';
 
 // Types
-import { PluginConfigTabProps, AppPluginMeta } from '@grafana/ui';
+import { PluginConfigPageProps, AppPlugin } from '@grafana/ui';
 
-interface Props extends PluginConfigTabProps<AppPluginMeta> {}
+interface Props extends PluginConfigPageProps<AppPlugin> {}
 
-export class ExampleTab2 extends PureComponent<Props> {
+export class ExamplePage2 extends PureComponent<Props> {
   constructor(props: Props) {
     super(props);
   }

+ 10 - 10
public/app/plugins/app/example-app/module.ts

@@ -2,8 +2,8 @@
 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 { ExamplePage1 } from './config/ExamplePage1';
+import { ExamplePage2 } from './config/ExamplePage2';
 import { ExampleRootPage } from './ExampleRootPage';
 
 // Legacy exports just for testing
@@ -14,15 +14,15 @@ export {
 
 export const plugin = new AppPlugin()
   .setRootPage(ExampleRootPage)
-  .addConfigTab({
-    title: 'Tab 1',
+  .addConfigPage({
+    title: 'Page 1',
     icon: 'fa fa-info',
-    body: ExampleTab1,
-    id: 'tab1',
+    body: ExamplePage1,
+    id: 'page1',
   })
-  .addConfigTab({
-    title: 'Tab 2',
+  .addConfigPage({
+    title: 'Page 2',
     icon: 'fa fa-user',
-    body: ExampleTab2,
-    id: 'tab2',
+    body: ExamplePage2,
+    id: 'page2',
   });

+ 28 - 0
public/app/plugins/datasource/testdata/TestInfoTab.tsx

@@ -0,0 +1,28 @@
+// Libraries
+import React, { PureComponent } from 'react';
+
+// Types
+import { PluginConfigPageProps, DataSourcePlugin } from '@grafana/ui';
+import { TestDataDatasource } from './datasource';
+
+interface Props extends PluginConfigPageProps<DataSourcePlugin<TestDataDatasource>> {}
+
+export class TestInfoTab extends PureComponent<Props> {
+  constructor(props: Props) {
+    super(props);
+  }
+
+  render() {
+    return (
+      <div>
+        See github for more information about setting up a reproducable test environment.
+        <br />
+        <br />
+        <a className="btn btn-inverse" href="https://github.com/grafana/grafana/tree/master/devenv" target="_blank">
+          Github
+        </a>
+        <br />
+      </div>
+    );
+  }
+}

+ 8 - 1
public/app/plugins/datasource/testdata/module.tsx

@@ -1,6 +1,7 @@
 import { DataSourcePlugin } from '@grafana/ui';
 import { TestDataDatasource } from './datasource';
 import { TestDataQueryCtrl } from './query_ctrl';
+import { TestInfoTab } from './TestInfoTab';
 import { ConfigEditor } from './ConfigEditor';
 
 class TestDataAnnotationsQueryCtrl {
@@ -12,4 +13,10 @@ class TestDataAnnotationsQueryCtrl {
 export const plugin = new DataSourcePlugin(TestDataDatasource)
   .setConfigEditor(ConfigEditor)
   .setQueryCtrl(TestDataQueryCtrl)
-  .setAnnotationQueryCtrl(TestDataAnnotationsQueryCtrl);
+  .setAnnotationQueryCtrl(TestDataAnnotationsQueryCtrl)
+  .addConfigPage({
+    title: 'Setup',
+    icon: 'fa fa-list-alt',
+    body: TestInfoTab,
+    id: 'setup',
+  });

+ 1 - 0
public/app/routes/routes.ts

@@ -110,6 +110,7 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
     })
     .when('/datasources/edit/:id/', {
       template: '<react-container />',
+      reloadOnSearch: false, // for tabs
       resolve: {
         component: () => DataSourceSettingsPage,
       },