Commit 013f1b8d by Ryan McKinley Committed by GitHub

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

parent 31ea0122
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;
}
}
}
}
}
......@@ -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>>;
......
......@@ -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';
......
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> {
// 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;
}
}
......@@ -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)
......
......@@ -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
......
......@@ -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);
......@@ -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 }) => {
......
// 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));
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>
);
}
}
......@@ -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} />
......
......@@ -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"
......
import './plugin_edit_ctrl';
import './plugin_page_ctrl';
import './import_list/import_list';
import './datasource_srv';
import './plugin_component';
import './variableQueryEditorLoader';
<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>
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);
<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>
......@@ -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>
......
......@@ -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],
};
});
}
......
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);
// 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();
};
}
// 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>
);
}
}
// 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>
);
}
}
// 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>
);
}
}
{
"__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
}
{
"__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
}
// 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',
});
......@@ -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?"
}
]
}
......@@ -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',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment