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

This commit is contained in:
Ryan McKinley 2019-05-02 10:15:39 -07:00 committed by GitHub
parent 31ea0122a0
commit 013f1b8d19
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1316 additions and 450 deletions

View File

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

View File

@ -25,11 +25,6 @@ export class DataSourcePlugin<TOptions = {}, TQuery extends DataQuery = DataQuer
return this;
}
setConfigCtrl(ConfigCtrl: any) {
this.components.ConfigCtrl = ConfigCtrl;
return this;
}
setQueryCtrl(QueryCtrl: any) {
this.components.QueryCtrl = QueryCtrl;
return this;
@ -60,14 +55,15 @@ export class DataSourcePlugin<TOptions = {}, TQuery extends DataQuery = DataQuer
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> {
QueryCtrl?: any;
ConfigCtrl?: any;
AnnotationsQueryCtrl?: any;
VariableQueryEditor?: any;
QueryEditor?: ComponentClass<QueryEditorProps<DataSourceApi, TQuery>>;

View File

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

View File

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

View File

@ -59,8 +59,10 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/styleguide", reqSignedIn, hs.Index)
r.Get("/plugins", reqSignedIn, hs.Index)
r.Get("/plugins/:id/edit", reqSignedIn, hs.Index)
r.Get("/plugins/:id/", reqSignedIn, hs.Index)
r.Get("/plugins/:id/edit", reqSignedIn, hs.Index) // deprecated
r.Get("/plugins/:id/page/:page", reqSignedIn, hs.Index)
r.Get("/a/:id/*", reqSignedIn, hs.Index) // App Root Page
r.Get("/d/:uid/:slug", reqSignedIn, hs.Index)
r.Get("/d/:uid", reqSignedIn, hs.Index)

View File

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

View File

@ -49,18 +49,25 @@ export class NavModelSrv {
}
getNotFoundNav() {
const node = {
text: 'Page not found',
icon: 'fa fa-fw fa-warning',
subTitle: '404 Error',
};
return {
breadcrumbs: [node],
node: node,
main: node,
};
return getNotFoundNav(); // the exported function
}
}
export function getNotFoundNav(): NavModel {
return getWarningNav('Page not found', '404 Error');
}
export function getWarningNav(text: string, subTitle?: string): NavModel {
const node = {
text,
subTitle,
icon: 'fa fa-fw fa-warning',
};
return {
breadcrumbs: [node],
node: node,
main: node,
};
}
coreModule.service('navModelSrv', NavModelSrv);

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ const PluginListItem: FC<Props> = props => {
return (
<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-type">
<i className={icon} />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,9 +12,9 @@
<div class="modal-content">
<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>
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
}

View File

@ -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
}

View File

@ -1,9 +1,28 @@
// Angular pages
import { ExampleConfigCtrl } from './legacy/config';
import { AngularExamplePageCtrl } from './legacy/angular_example_page';
import { AppPlugin } from '@grafana/ui';
import { ExampleTab1 } from './config/ExampleTab1';
import { ExampleTab2 } from './config/ExampleTab2';
import { ExampleRootPage } from './ExampleRootPage';
// Legacy exports just for testing
export {
ExampleConfigCtrl as ConfigCtrl,
// Must match `pages.component` in plugin.json
AngularExamplePageCtrl,
AngularExamplePageCtrl, // Must match `pages.component` in plugin.json
};
export const plugin = new AppPlugin()
.setRootPage(ExampleRootPage)
.addConfigTab({
title: 'Tab 1',
icon: 'fa fa-info',
body: ExampleTab1,
id: 'tab1',
})
.addConfigTab({
title: 'Tab 2',
icon: 'fa fa-user',
body: ExampleTab2,
id: 'tab2',
});

View File

@ -23,6 +23,20 @@
"role": "Viewer",
"addToNav": true,
"defaultNav": true
},
{
"type": "dashboard",
"name": "Streaming Example",
"path": "dashboards/streaming.json"
},
{
"type": "dashboard",
"name": "Lots of Stats",
"path": "dashboards/stats.json"
},
{
"type": "panel",
"name": "Anything -- just display?"
}
]
}

View File

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