Browse Source

App Plugins: support react pages and tabs (#16586)

Ryan McKinley 6 years ago
parent
commit
013f1b8d19
29 changed files with 1315 additions and 449 deletions
  1. 62 0
      packages/grafana-ui/src/types/app.ts
  2. 9 14
      packages/grafana-ui/src/types/datasource.ts
  3. 1 0
      packages/grafana-ui/src/types/index.ts
  4. 45 32
      packages/grafana-ui/src/types/plugin.ts
  5. 3 1
      pkg/api/api.go
  6. 1 1
      pkg/api/plugins.go
  7. 18 11
      public/app/core/nav_model_srv.ts
  8. 2 2
      public/app/features/datasources/DashboardsTable.tsx
  9. 103 0
      public/app/features/plugins/AppRootPage.tsx
  10. 112 0
      public/app/features/plugins/PluginDashboards.tsx
  11. 1 1
      public/app/features/plugins/PluginListItem.tsx
  12. 415 0
      public/app/features/plugins/PluginPage.tsx
  13. 2 2
      public/app/features/plugins/__snapshots__/PluginListItem.test.tsx.snap
  14. 0 2
      public/app/features/plugins/all.ts
  15. 0 30
      public/app/features/plugins/import_list/import_list.html
  16. 0 92
      public/app/features/plugins/import_list/import_list.ts
  17. 0 69
      public/app/features/plugins/partials/plugin_edit.html
  18. 3 3
      public/app/features/plugins/partials/update_instructions.html
  19. 3 3
      public/app/features/plugins/plugin_component.ts
  20. 0 180
      public/app/features/plugins/plugin_edit_ctrl.ts
  21. 139 0
      public/app/features/plugins/wrappers/AppConfigWrapper.tsx
  22. 102 0
      public/app/plugins/app/example-app/ExampleRootPage.tsx
  23. 25 0
      public/app/plugins/app/example-app/config/ExampleTab1.tsx
  24. 25 0
      public/app/plugins/app/example-app/config/ExampleTab2.tsx
  25. 110 0
      public/app/plugins/app/example-app/dashboards/stats.json
  26. 83 0
      public/app/plugins/app/example-app/dashboards/streaming.json
  27. 21 2
      public/app/plugins/app/example-app/module.ts
  28. 14 0
      public/app/plugins/app/example-app/plugin.json
  29. 16 4
      public/app/routes/routes.ts

+ 62 - 0
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<AppPluginMeta> {
+  // Content under: /a/${plugin-id}/*
+  root?: ComponentClass<AppRootProps>;
+  rootNav?: NavModel; // Initial navigation model
+
+  // Old style pages
+  angularPages?: { [component: string]: any };
+
+  /**
+   * Set the component displayed under:
+   *   /a/${plugin-id}/*
+   */
+  setRootPage(root: ComponentClass<AppRootProps>, 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;
+        }
+      }
+    }
+  }
+}

+ 9 - 14
packages/grafana-ui/src/types/datasource.ts

@@ -25,11 +25,6 @@ export class DataSourcePlugin<TOptions = {}, TQuery extends DataQuery = DataQuer
     return this;
     return this;
   }
   }
 
 
-  setConfigCtrl(ConfigCtrl: any) {
-    this.components.ConfigCtrl = ConfigCtrl;
-    return this;
-  }
-
   setQueryCtrl(QueryCtrl: any) {
   setQueryCtrl(QueryCtrl: any) {
     this.components.QueryCtrl = QueryCtrl;
     this.components.QueryCtrl = QueryCtrl;
     return this;
     return this;
@@ -60,14 +55,15 @@ export class DataSourcePlugin<TOptions = {}, TQuery extends DataQuery = DataQuer
     return this;
     return this;
   }
   }
 
 
-  setComponentsFromLegacyExports(exports: any) {
-    this.components.ConfigCtrl = exports.ConfigCtrl;
-    this.components.QueryCtrl = exports.QueryCtrl;
-    this.components.AnnotationsQueryCtrl = exports.AnnotationsQueryCtrl;
-    this.components.ExploreQueryField = exports.ExploreQueryField;
-    this.components.ExploreStartPage = exports.ExploreStartPage;
-    this.components.QueryEditor = exports.QueryEditor;
-    this.components.VariableQueryEditor = exports.VariableQueryEditor;
+  setComponentsFromLegacyExports(pluginExports: any) {
+    this.angularConfigCtrl = pluginExports.ConfigCtrl;
+
+    this.components.QueryCtrl = pluginExports.QueryCtrl;
+    this.components.AnnotationsQueryCtrl = pluginExports.AnnotationsQueryCtrl;
+    this.components.ExploreQueryField = pluginExports.ExploreQueryField;
+    this.components.ExploreStartPage = pluginExports.ExploreStartPage;
+    this.components.QueryEditor = pluginExports.QueryEditor;
+    this.components.VariableQueryEditor = pluginExports.VariableQueryEditor;
   }
   }
 }
 }
 
 
@@ -91,7 +87,6 @@ interface PluginMetaQueryOptions {
 
 
 export interface DataSourcePluginComponents<TOptions = {}, TQuery extends DataQuery = DataQuery> {
 export interface DataSourcePluginComponents<TOptions = {}, TQuery extends DataQuery = DataQuery> {
   QueryCtrl?: any;
   QueryCtrl?: any;
-  ConfigCtrl?: any;
   AnnotationsQueryCtrl?: any;
   AnnotationsQueryCtrl?: any;
   VariableQueryEditor?: any;
   VariableQueryEditor?: any;
   QueryEditor?: ComponentClass<QueryEditorProps<DataSourceApi, TQuery>>;
   QueryEditor?: ComponentClass<QueryEditorProps<DataSourceApi, TQuery>>;

+ 1 - 0
packages/grafana-ui/src/types/index.ts

@@ -2,6 +2,7 @@ export * from './data';
 export * from './time';
 export * from './time';
 export * from './panel';
 export * from './panel';
 export * from './plugin';
 export * from './plugin';
+export * from './app';
 export * from './datasource';
 export * from './datasource';
 export * from './theme';
 export * from './theme';
 export * from './graph';
 export * from './graph';

+ 45 - 32
packages/grafana-ui/src/types/plugin.ts

@@ -1,3 +1,5 @@
+import { ComponentClass } from 'react';
+
 export enum PluginState {
 export enum PluginState {
   alpha = 'alpha', // Only included it `enable_alpha` is true
   alpha = 'alpha', // Only included it `enable_alpha` is true
   beta = 'beta', // Will show a warning banner
   beta = 'beta', // Will show a warning banner
@@ -21,8 +23,12 @@ export interface PluginMeta {
   module: string;
   module: string;
   baseUrl: string;
   baseUrl: string;
 
 
+  // Define plugin requirements
+  dependencies?: PluginDependencies;
+
   // Filled in by the backend
   // Filled in by the backend
   jsonData?: { [str: string]: any };
   jsonData?: { [str: string]: any };
+  secureJsonData?: { [str: string]: any };
   enabled?: boolean;
   enabled?: boolean;
   defaultNavUrl?: string;
   defaultNavUrl?: string;
   hasUpdate?: boolean;
   hasUpdate?: boolean;
@@ -30,6 +36,18 @@ export interface PluginMeta {
   pinned?: boolean;
   pinned?: boolean;
 }
 }
 
 
+interface PluginDependencyInfo {
+  id: string;
+  name: string;
+  version: string;
+  type: PluginType;
+}
+
+export interface PluginDependencies {
+  grafanaVersion: string;
+  plugins: PluginDependencyInfo[];
+}
+
 export enum PluginIncludeType {
 export enum PluginIncludeType {
   dashboard = 'dashboard',
   dashboard = 'dashboard',
   page = 'page',
   page = 'page',
@@ -44,6 +62,10 @@ export interface PluginInclude {
   name: string;
   name: string;
   path?: string;
   path?: string;
   icon?: string;
   icon?: string;
+
+  role?: string; // "Viewer", Admin, editor???
+  addToNav?: boolean; // Show in the sidebar... only if type=page?
+
   // Angular app pages
   // Angular app pages
   component?: string;
   component?: string;
 }
 }
@@ -69,44 +91,35 @@ export interface PluginMetaInfo {
   version: string;
   version: string;
 }
 }
 
 
-export class GrafanaPlugin<T extends PluginMeta> {
-  // Meta is filled in by the plugin loading system
-  meta?: T;
+export interface PluginConfigTabProps<T extends PluginMeta> {
+  meta: T;
+  query: { [s: string]: any }; // The URL query parameters
+}
+
+export interface PluginConfigTab<T extends PluginMeta> {
+  title: string; // Display
+  icon?: string;
+  id: string; // Unique, in URL
 
 
-  // Soon this will also include common config options
+  body: ComponentClass<PluginConfigTabProps<T>>;
 }
 }
 
 
-export class AppPlugin extends GrafanaPlugin<PluginMeta> {
-  angular?: {
-    ConfigCtrl?: any;
-    pages: { [component: string]: any };
-  };
+export class GrafanaPlugin<T extends PluginMeta = PluginMeta> {
+  // Meta is filled in by the plugin loading system
+  meta?: T;
 
 
-  setComponentsFromLegacyExports(pluginExports: any) {
-    const legacy = {
-      ConfigCtrl: undefined,
-      pages: {} as any,
-    };
+  // Config control (app/datasource)
+  angularConfigCtrl?: any;
 
 
-    if (pluginExports.ConfigCtrl) {
-      legacy.ConfigCtrl = pluginExports.ConfigCtrl;
-      this.angular = legacy;
-    }
+  // Show configuration tabs on the plugin page
+  configTabs?: Array<PluginConfigTab<T>>;
 
 
-    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<T>) {
+    if (!this.configTabs) {
+      this.configTabs = [];
     }
     }
+    this.configTabs.push(tab);
+    return this;
   }
   }
 }
 }

+ 3 - 1
pkg/api/api.go

@@ -59,8 +59,10 @@ func (hs *HTTPServer) registerRoutes() {
 	r.Get("/styleguide", reqSignedIn, hs.Index)
 	r.Get("/styleguide", reqSignedIn, hs.Index)
 
 
 	r.Get("/plugins", 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("/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/:slug", reqSignedIn, hs.Index)
 	r.Get("/d/:uid", reqSignedIn, hs.Index)
 	r.Get("/d/:uid", reqSignedIn, hs.Index)

+ 1 - 1
pkg/api/plugins.go

@@ -60,7 +60,7 @@ func (hs *HTTPServer) GetPluginList(c *m.ReqContext) Response {
 		}
 		}
 
 
 		if listItem.DefaultNavUrl == "" || !listItem.Enabled {
 		if listItem.DefaultNavUrl == "" || !listItem.Enabled {
-			listItem.DefaultNavUrl = setting.AppSubUrl + "/plugins/" + listItem.Id + "/edit"
+			listItem.DefaultNavUrl = setting.AppSubUrl + "/plugins/" + listItem.Id + "/"
 		}
 		}
 
 
 		// filter out disabled
 		// filter out disabled

+ 18 - 11
public/app/core/nav_model_srv.ts

@@ -49,18 +49,25 @@ export class NavModelSrv {
   }
   }
 
 
   getNotFoundNav() {
   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);
 coreModule.service('navModelSrv', NavModelSrv);

+ 2 - 2
public/app/features/datasources/DashboardsTable.tsx

@@ -3,8 +3,8 @@ import { PluginDashboard } from '../../types';
 
 
 export interface Props {
 export interface Props {
   dashboards: PluginDashboard[];
   dashboards: PluginDashboard[];
-  onImport: (dashboard, overwrite) => void;
-  onRemove: (dashboard) => void;
+  onImport: (dashboard: PluginDashboard, overwrite: boolean) => void;
+  onRemove: (dashboard: PluginDashboard) => void;
 }
 }
 
 
 const DashboardsTable: FC<Props> = ({ dashboards, onImport, onRemove }) => {
 const DashboardsTable: FC<Props> = ({ dashboards, onImport, onRemove }) => {

+ 103 - 0
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<Props, State> {
+  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 <div>No Root App</div>;
+    }
+
+    return (
+      <Page navModel={nav}>
+        <Page.Contents isLoading={loading}>
+          {!loading && plugin && (
+            <plugin.root meta={plugin.meta} query={query} path={path} onNavChanged={this.onNavChanged} />
+          )}
+        </Page.Contents>
+      </Page>
+    );
+  }
+}
+
+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));

+ 112 - 0
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<Props, State> {
+  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 <div>loading...</div>;
+    }
+    if (!dashboards || !dashboards.length) {
+      return <div>No dashboards are included with this plugin</div>;
+    }
+
+    return (
+      <div className="gf-form-group">
+        <DashboardsTable dashboards={dashboards} onImport={this.import} onRemove={this.remove} />
+      </div>
+    );
+  }
+}

+ 1 - 1
public/app/features/plugins/PluginListItem.tsx

@@ -19,7 +19,7 @@ const PluginListItem: FC<Props> = props => {
 
 
   return (
   return (
     <li className="card-item-wrapper">
     <li className="card-item-wrapper">
-      <a className="card-item" href={`plugins/${plugin.id}/edit`}>
+      <a className="card-item" href={`plugins/${plugin.id}/`}>
         <div className="card-item-header">
         <div className="card-item-header">
           <div className="card-item-type">
           <div className="card-item-type">
             <i className={icon} />
             <i className={icon} />

+ 415 - 0
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<GrafanaPlugin> {
+  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<Props, State> {
+  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 <div>Plugin not found.</div>;
+    }
+
+    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 <tab.body meta={plugin.meta} query={query} />;
+          }
+        }
+      }
+
+      // Apps have some special behavior
+      if (plugin.meta.type === PluginType.app) {
+        if (active.id === TAB_ID_DASHBOARDS) {
+          return <PluginDashboards plugin={plugin.meta} />;
+        }
+
+        if (active.id === TAB_ID_CONFIG_CTRL && plugin.angularConfigCtrl) {
+          return <AppConfigCtrlWrapper app={plugin as AppPlugin} />;
+        }
+      }
+    }
+
+    return <PluginHelp plugin={plugin.meta} type="help" />;
+  }
+
+  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 (
+      <section className="page-sidebar-section">
+        <h4>Version</h4>
+        <span>{meta.info.version}</span>
+        {meta.hasUpdate && (
+          <div>
+            <Tooltip content={meta.latestVersion} theme="info" placement="top">
+              <a href="#" onClick={this.showUpdateInfo}>
+                Update Available!
+              </a>
+            </Tooltip>
+          </div>
+        )}
+      </section>
+    );
+  }
+
+  renderSidebarIncludeBody(item: PluginInclude) {
+    if (item.type === PluginIncludeType.page) {
+      const pluginId = this.state.plugin.meta.id;
+      const page = item.name.toLowerCase().replace(' ', '-');
+      return (
+        <a href={`plugins/${pluginId}/page/${page}`}>
+          <i className={getPluginIcon(item.type)} />
+          {item.name}
+        </a>
+      );
+    }
+    return (
+      <>
+        <i className={getPluginIcon(item.type)} />
+        {item.name}
+      </>
+    );
+  }
+
+  renderSidebarIncludes(includes: PluginInclude[]) {
+    if (!includes || !includes.length) {
+      return null;
+    }
+
+    return (
+      <section className="page-sidebar-section">
+        <h4>Includes</h4>
+        <ul className="ui-list plugin-info-list">
+          {includes.map(include => {
+            return (
+              <li className="plugin-info-list-item" key={include.name}>
+                {this.renderSidebarIncludeBody(include)}
+              </li>
+            );
+          })}
+        </ul>
+      </section>
+    );
+  }
+
+  renderSidebarDependencies(dependencies: PluginDependencies) {
+    if (!dependencies) {
+      return null;
+    }
+
+    return (
+      <section className="page-sidebar-section">
+        <h4>Dependencies</h4>
+        <ul className="ui-list plugin-info-list">
+          <li className="plugin-info-list-item">
+            <img src="public/img/grafana_icon.svg" />
+            Grafana {dependencies.grafanaVersion}
+          </li>
+          {dependencies.plugins &&
+            dependencies.plugins.map(plug => {
+              return (
+                <li className="plugin-info-list-item" key={plug.name}>
+                  <i className={getPluginIcon(plug.type)} />
+                  {plug.name} {plug.version}
+                </li>
+              );
+            })}
+        </ul>
+      </section>
+    );
+  }
+
+  renderSidebarLinks(info: PluginMetaInfo) {
+    if (!info.links || !info.links.length) {
+      return null;
+    }
+
+    return (
+      <section className="page-sidebar-section">
+        <h4>Links</h4>
+        <ul className="ui-list">
+          {info.links.map(link => {
+            return (
+              <li key={link.url}>
+                <a href={link.url} className="external-link" target="_blank">
+                  {link.name}
+                </a>
+              </li>
+            );
+          })}
+        </ul>
+      </section>
+    );
+  }
+
+  render() {
+    const { loading, nav, plugin } = this.state;
+    return (
+      <Page navModel={nav}>
+        <Page.Contents isLoading={loading}>
+          {!loading && (
+            <div className="sidebar-container">
+              <div className="sidebar-content">{this.renderBody()}</div>
+              <aside className="page-sidebar">
+                {plugin && (
+                  <section className="page-sidebar-section">
+                    {this.renderVersionInfo(plugin.meta)}
+                    {this.renderSidebarIncludes(plugin.meta.includes)}
+                    {this.renderSidebarDependencies(plugin.meta.dependencies)}
+                    {this.renderSidebarLinks(plugin.meta.info)}
+                  </section>
+                )}
+              </aside>
+            </div>
+          )}
+        </Page.Contents>
+      </Page>
+    );
+  }
+}
+
+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));

+ 2 - 2
public/app/features/plugins/__snapshots__/PluginListItem.test.tsx.snap

@@ -6,7 +6,7 @@ exports[`Render should render component 1`] = `
 >
 >
   <a
   <a
     className="card-item"
     className="card-item"
-    href="plugins/1/edit"
+    href="plugins/1/"
   >
   >
     <div
     <div
       className="card-item-header"
       className="card-item-header"
@@ -55,7 +55,7 @@ exports[`Render should render has plugin section 1`] = `
 >
 >
   <a
   <a
     className="card-item"
     className="card-item"
-    href="plugins/1/edit"
+    href="plugins/1/"
   >
   >
     <div
     <div
       className="card-item-header"
       className="card-item-header"

+ 0 - 2
public/app/features/plugins/all.ts

@@ -1,6 +1,4 @@
-import './plugin_edit_ctrl';
 import './plugin_page_ctrl';
 import './plugin_page_ctrl';
-import './import_list/import_list';
 import './datasource_srv';
 import './datasource_srv';
 import './plugin_component';
 import './plugin_component';
 import './variableQueryEditorLoader';
 import './variableQueryEditorLoader';

+ 0 - 30
public/app/features/plugins/import_list/import_list.html

@@ -1,30 +0,0 @@
-<div class="gf-form-group" ng-if="ctrl.dashboards.length">
-	<table class="filter-table">
-		<tbody>
-			<tr ng-repeat="dash in ctrl.dashboards">
-				<td class="width-1">
-					<i class="gicon gicon-dashboard"></i>
-				</td>
-				<td>
-					<a href="{{dash.importedUrl}}" ng-show="dash.imported">
-						{{dash.title}}
-					</a>
-					<span ng-show="!dash.imported">{{dash.title}}</span>
-				</td>
-				<td style="text-align: right">
-					<button class="btn btn-secondary btn-small" ng-click="ctrl.import(dash, false)" ng-show="!dash.imported">
-						Import
-					</button>
-					<button class="btn btn-secondary btn-small" ng-click="ctrl.import(dash, true)" ng-show="dash.imported">
-            <span ng-if="dash.revision !== dash.importedRevision">Update</span>
-            <span ng-if="dash.revision === dash.importedRevision">Re-import</span>
-					</button>
-					<button class="btn btn-danger btn-small" ng-click="ctrl.remove(dash)" ng-show="dash.imported">
-						<i class="fa fa-trash"></i>
-					</button>
-				</td>
-			</tr>
-		</tbody>
-	</table>
-</div>
-

+ 0 - 92
public/app/features/plugins/import_list/import_list.ts

@@ -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);

+ 0 - 69
public/app/features/plugins/partials/plugin_edit.html

@@ -1,69 +0,0 @@
-<div ng-if="ctrl.navModel">
-  <page-header model="ctrl.navModel"></page-header>
-
-  <div class="page-container page-body">
-    <div class="sidebar-container">
-      <div class="tab-content sidebar-content" ng-if="ctrl.tab === 'readme'">
-        <div ng-bind-html="ctrl.readmeHtml" class="markdown-html">
-        </div>
-      </div>
-
-      <div class="tab-content sidebar-content" ng-if="ctrl.tab === 'config'">
-        <div ng-if="ctrl.model.id">
-          <plugin-component type="app-config-ctrl"></plugin-component>
-
-          <div class="gf-form-button-row">
-            <button type="submit" class="btn btn-primary" ng-click="ctrl.enable()" ng-show="!ctrl.model.enabled">Enable</button>
-            <button type="submit" class="btn btn-primary" ng-click="ctrl.update()" ng-show="ctrl.model.enabled">Update</button>
-            <button type="submit" class="btn btn-danger" ng-click="ctrl.disable()" ng-show="ctrl.model.enabled">Disable</button>
-          </div>
-
-        </div>
-      </div>
-
-      <div class="tab-content sidebar-content" ng-if="ctrl.tab === 'dashboards'">
-        <dashboard-import-list plugin="ctrl.model"></dashboard-import-list>
-      </div>
-
-      <aside class="page-sidebar">
-        <section class="page-sidebar-section" ng-if="ctrl.model.info.version">
-          <h4>Version</h4>
-          <span>{{ctrl.model.info.version}}</span>
-          <div ng-show="ctrl.model.hasUpdate">
-            <a ng-click="ctrl.updateAvailable()" bs-tooltip="ctrl.model.latestVersion">Update Available!</a>
-          </div>
-        </section>
-        <section class="page-sidebar-section" ng-show="ctrl.model.type === 'app'">
-          <h5>Includes</h4>
-          <ul class="ui-list plugin-info-list">
-            <li ng-repeat="plug in ctrl.includes" class="plugin-info-list-item">
-              <i class="{{plug.icon}}"></i>
-              {{plug.name}}
-            </li>
-          </ul>
-        </section>
-        <section class="page-sidebar-section">
-          <h5>Dependencies</h4>
-          <ul class="ui-list plugin-info-list">
-            <li class="plugin-info-list-item">
-              <img src="public/img/grafana_icon.svg"></img>
-              Grafana {{ctrl.model.dependencies.grafanaVersion}}
-            </li>
-            <li ng-repeat="plugDep in ctrl.model.dependencies.plugins" class="plugin-info-list-item">
-              <i class="{{plugDep.icon}}"></i>
-              {{plugDep.name}} {{plugDep.version}}
-            </li>
-          </ul>
-        </section>
-        <section class="page-sidebar-section" ng-if="ctrl.model.info.links">
-          <h5>Links</h4>
-          <ul class="ui-list">
-            <li ng-repeat="link in ctrl.model.info.links">
-              <a href="{{link.url}}" class="external-link" target="_blank">{{link.name}}</a>
-            </li>
-          </ul>
-        </section>
-      </aside>
-    </div>
-  </div>
-</div>

+ 3 - 3
public/app/features/plugins/partials/update_instructions.html

@@ -12,9 +12,9 @@
 
 
 	<div class="modal-content">
 	<div class="modal-content">
 		<div class="gf-form-group">
 		<div class="gf-form-group">
-			<p>Type the following on the command line to update {{plugin.name}}.</p>
-			<pre><code>grafana-cli plugins update {{plugin.id}}</code></pre>
-			<span class="small">Check out {{plugin.name}} on <a href="https://grafana.com/plugins/{{plugin.id}}">Grafana.com</a> for README and changelog. If you do not have access to the command line, ask your Grafana administator.</span>
+			<p>Type the following on the command line to update {{model.name}}.</p>
+			<pre><code>grafana-cli plugins update {{model.id}}</code></pre>
+			<span class="small">Check out {{model.name}} on <a href="https://grafana.com/plugins/{{model.id}}">Grafana.com</a> for README and changelog. If you do not have access to the command line, ask your Grafana administator.</span>
 		</div>
 		</div>
 		<p class="pluginlist-none-installed"><img class="pluginlist-inline-logo" src="public/img/grafana_icon.svg"><strong>Pro tip</strong>: To update all plugins at once, type <code class="code--small">grafana-cli plugins update-all</code> on the command line.</div>
 		<p class="pluginlist-none-installed"><img class="pluginlist-inline-logo" src="public/img/grafana_icon.svg"><strong>Pro tip</strong>: To update all plugins at once, type <code class="code--small">grafana-cli plugins update-all</code> on the command line.</div>
 	</div>
 	</div>

+ 3 - 3
public/app/features/plugins/plugin_component.ts

@@ -147,7 +147,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
             name: 'ds-config-' + dsMeta.id,
             name: 'ds-config-' + dsMeta.id,
             bindings: { meta: '=', current: '=' },
             bindings: { meta: '=', current: '=' },
             attrs: { meta: 'ctrl.datasourceMeta', current: 'ctrl.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,
             name: 'app-config-' + model.id,
             bindings: { appModel: '=', appEditCtrl: '=' },
             bindings: { appModel: '=', appEditCtrl: '=' },
             attrs: { 'app-model': 'ctrl.model', 'app-edit-ctrl': 'ctrl' },
             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,
             name: 'app-page-' + appModel.id + '-' + scope.ctrl.page.slug,
             bindings: { appModel: '=' },
             bindings: { appModel: '=' },
             attrs: { 'app-model': 'ctrl.appModel' },
             attrs: { 'app-model': 'ctrl.appModel' },
-            Component: appPlugin.angular.pages[scope.ctrl.page.component],
+            Component: appPlugin.angularPages[scope.ctrl.page.component],
           };
           };
         });
         });
       }
       }

+ 0 - 180
public/app/features/plugins/plugin_edit_ctrl.ts

@@ -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);

+ 139 - 0
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<Props, State> {
+  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 = '<plugin-component type="app-config-ctrl"></plugin-component>';
+    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 (
+      <div>
+        <div ref={element => (this.element = element)} />
+        <br />
+        <br />
+        {model && (
+          <div className="gf-form">
+            {!model.enabled && (
+              <Button variant={ButtonVariant.Primary} onClick={this.enable} className={withRightMargin}>
+                Enable
+              </Button>
+            )}
+            {model.enabled && (
+              <Button variant={ButtonVariant.Primary} onClick={this.update} className={withRightMargin}>
+                Update
+              </Button>
+            )}
+            {model.enabled && (
+              <Button variant={ButtonVariant.Danger} onClick={this.disable} className={withRightMargin}>
+                Disable
+              </Button>
+            )}
+          </div>
+        )}
+      </div>
+    );
+  }
+
+  //-----------------------------------------------------------
+  // 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();
+  };
+}

+ 102 - 0
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<Props> {
+  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 (
+      <div>
+        QUERY: <pre>{JSON.stringify(query)}</pre>
+        <br />
+        <ul>
+          <li>
+            <a href={path + '?x=1'}>111</a>
+          </li>
+          <li>
+            <a href={path + '?x=AAA'}>AAA</a>
+          </li>
+          <li>
+            <a href={path + '?x=1&y=2&y=3'}>ZZZ</a>
+          </li>
+        </ul>
+      </div>
+    );
+  }
+}

+ 25 - 0
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<AppPluginMeta> {}
+
+export class ExampleTab1 extends PureComponent<Props> {
+  constructor(props: Props) {
+    super(props);
+  }
+
+  render() {
+    const { query } = this.props;
+
+    return (
+      <div>
+        11111111111111111111111111111111
+        <pre>{JSON.stringify(query)}</pre>
+        11111111111111111111111111111111
+      </div>
+    );
+  }
+}

+ 25 - 0
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<AppPluginMeta> {}
+
+export class ExampleTab2 extends PureComponent<Props> {
+  constructor(props: Props) {
+    super(props);
+  }
+
+  render() {
+    const { query } = this.props;
+
+    return (
+      <div>
+        22222222222222222222222222222222
+        <pre>{JSON.stringify(query)}</pre>
+        22222222222222222222222222222222
+      </div>
+    );
+  }
+}

+ 110 - 0
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
+}

+ 83 - 0
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
+}

+ 21 - 2
public/app/plugins/app/example-app/module.ts

@@ -1,9 +1,28 @@
 // Angular pages
 // Angular pages
 import { ExampleConfigCtrl } from './legacy/config';
 import { ExampleConfigCtrl } from './legacy/config';
 import { AngularExamplePageCtrl } from './legacy/angular_example_page';
 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 {
 export {
   ExampleConfigCtrl as ConfigCtrl,
   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',
+  });

+ 14 - 0
public/app/plugins/app/example-app/plugin.json

@@ -23,6 +23,20 @@
       "role": "Viewer",
       "role": "Viewer",
       "addToNav": true,
       "addToNav": true,
       "defaultNav": 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?"
     }
     }
   ]
   ]
 }
 }

+ 16 - 4
public/app/routes/routes.ts

@@ -22,6 +22,8 @@ import DataSourceSettingsPage from '../features/datasources/settings/DataSourceS
 import OrgDetailsPage from '../features/org/OrgDetailsPage';
 import OrgDetailsPage from '../features/org/OrgDetailsPage';
 import SoloPanelPage from '../features/dashboard/containers/SoloPanelPage';
 import SoloPanelPage from '../features/dashboard/containers/SoloPanelPage';
 import DashboardPage from '../features/dashboard/containers/DashboardPage';
 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';
 import config from 'app/core/config';
 
 
 // Types
 // Types
@@ -164,6 +166,14 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
         component: () => import(/* webpackChunkName: "explore" */ 'app/features/explore/Wrapper'),
         component: () => import(/* webpackChunkName: "explore" */ 'app/features/explore/Wrapper'),
       },
       },
     })
     })
+    .when('/a/:pluginId/', {
+      // Someday * and will get a ReactRouter under that path!
+      template: '<react-container />',
+      reloadOnSearch: false,
+      resolve: {
+        component: () => AppRootPage,
+      },
+    })
     .when('/org', {
     .when('/org', {
       template: '<react-container />',
       template: '<react-container />',
       resolve: {
       resolve: {
@@ -301,10 +311,12 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
         component: () => PluginListPage,
         component: () => PluginListPage,
       },
       },
     })
     })
-    .when('/plugins/:pluginId/edit', {
-      templateUrl: 'public/app/features/plugins/partials/plugin_edit.html',
-      controller: 'PluginEditCtrl',
-      controllerAs: 'ctrl',
+    .when('/plugins/:pluginId/', {
+      template: '<react-container />',
+      reloadOnSearch: false, // tabs from query parameters
+      resolve: {
+        component: () => PluginPage,
+      },
     })
     })
     .when('/plugins/:pluginId/page/:slug', {
     .when('/plugins/:pluginId/page/:slug', {
       templateUrl: 'public/app/features/plugins/partials/plugin_page.html',
       templateUrl: 'public/app/features/plugins/partials/plugin_page.html',