Merge branch 'master' into 12556-oauth-pass-thru

This commit is contained in:
Sean Lafferty
2019-03-13 14:25:43 -04:00
1039 changed files with 61352 additions and 17151 deletions

View File

@@ -1,9 +1,8 @@
export default class AdminEditOrgCtrl {
/** @ngInject */
constructor($scope, $routeParams, backendSrv, $location, navModelSrv) {
$scope.init = () => {
$scope.navModel = navModelSrv.getNav('cfg', 'admin', 'global-orgs', 1);
$scope.navModel = navModelSrv.getNav('admin', 'global-orgs', 0);
if ($routeParams.id) {
$scope.getOrg($routeParams.id);
@@ -46,4 +45,3 @@ export default class AdminEditOrgCtrl {
$scope.init();
}
}

View File

@@ -6,7 +6,7 @@ export default class AdminEditUserCtrl {
$scope.user = {};
$scope.newOrg = { name: '', role: 'Editor' };
$scope.permissions = {};
$scope.navModel = navModelSrv.getNav('cfg', 'admin', 'global-users', 1);
$scope.navModel = navModelSrv.getNav('admin', 'global-users', 0);
$scope.init = () => {
if ($routeParams.id) {

View File

@@ -1,9 +1,8 @@
export default class AdminListOrgsCtrl {
/** @ngInject */
constructor($scope, backendSrv, navModelSrv) {
$scope.init = () => {
$scope.navModel = navModelSrv.getNav('cfg', 'admin', 'global-orgs', 1);
$scope.navModel = navModelSrv.getNav('admin', 'global-orgs', 0);
$scope.getOrgs();
};
@@ -31,4 +30,3 @@ export default class AdminListOrgsCtrl {
$scope.init();
}
}

View File

@@ -10,7 +10,7 @@ export default class AdminListUsersCtrl {
/** @ngInject */
constructor(private $scope, private backendSrv, navModelSrv) {
this.navModel = navModelSrv.getNav('cfg', 'admin', 'global-users', 1);
this.navModel = navModelSrv.getNav('admin', 'global-users', 0);
this.query = '';
this.getUsers();
}

View File

@@ -22,7 +22,7 @@ export class ServerStats extends PureComponent<Props, State> {
this.state = {
stats: [],
isLoading: false
isLoading: false,
};
}

View File

@@ -9,7 +9,7 @@ export default class StyleGuideCtrl {
/** @ngInject */
constructor(private $routeParams, private backendSrv, navModelSrv) {
this.navModel = navModelSrv.getNav('cfg', 'admin', 'styleguide', 1);
this.navModel = navModelSrv.getNav('admin', 'styleguide', 0);
this.theme = config.bootData.user.lightTheme ? 'light' : 'dark';
}
@@ -25,4 +25,3 @@ export default class StyleGuideCtrl {
});
}
}

View File

@@ -5,7 +5,7 @@ exports[`ServerStats Should render table with stats 1`] = `
className="page-scrollbar-wrapper"
>
<div
className="custom-scrollbars"
className="custom-scrollbar custom-scrollbar--page"
style={
Object {
"height": "auto",

View File

@@ -11,7 +11,7 @@ class AdminSettingsCtrl {
/** @ngInject */
constructor($scope, backendSrv, navModelSrv) {
this.navModel = navModelSrv.getNav('cfg', 'admin', 'server-settings', 1);
this.navModel = navModelSrv.getNav('admin', 'server-settings', 0);
backendSrv.get('/api/admin/settings').then(settings => {
$scope.settings = settings;
@@ -24,7 +24,7 @@ class AdminHomeCtrl {
/** @ngInject */
constructor(navModelSrv) {
this.navModel = navModelSrv.getNav('cfg', 'admin', 1);
this.navModel = navModelSrv.getNav('admin', 0);
}
}

View File

@@ -10,7 +10,7 @@
</div>
<div class="gf-form-button-row">
<button type="submit" class="btn btn-success" ng-click="update()" ng-show="!createMode">Update</button>
<button type="submit" class="btn btn-primary" ng-click="update()" ng-show="!createMode">Update</button>
</div>
</form>

View File

@@ -21,7 +21,7 @@
</div>
<div class="gf-form-button-row">
<button type="submit" class="btn btn-success" ng-click="update()" ng-show="!createMode">Update</button>
<button type="submit" class="btn btn-primary" ng-click="update()" ng-show="!createMode">Update</button>
</div>
</form>
@@ -34,7 +34,7 @@
</div>
<div class="gf-form-button-row">
<button type="submit" class="btn btn-success" ng-click="setPassword()">Update</button>
<button type="submit" class="btn btn-primary" ng-click="setPassword()">Update</button>
</div>
</form>
@@ -46,7 +46,7 @@
</div>
<div class="gf-form-button-row">
<button type="submit" class="btn btn-success" ng-click="updatePermissions()">Update</button>
<button type="submit" class="btn btn-primary" ng-click="updatePermissions()">Update</button>
</div>
</form>
@@ -65,7 +65,7 @@
</span>
</div>
<div class="gf-form">
<button class="btn btn-success gf-form-btn" ng-click="addOrgUser()">Add</button>
<button class="btn btn-primary gf-form-btn" ng-click="addOrgUser()">Add</button>
</div>
</div>
</form>

View File

@@ -24,7 +24,7 @@
</div>
<div class="gf-form-button-row">
<button type="submit" class="btn btn-success" ng-click="create()">Create</button>
<button type="submit" class="btn btn-primary" ng-click="create()">Create</button>
</div>
</form>
</div>

View File

@@ -3,9 +3,8 @@
<div class="page-container page-body">
<div class="page-action-bar">
<div class="page-action-bar__spacer"></div>
<a class="page-header__cta btn btn-success" href="org/new">
<i class="fa fa-plus"></i>
New Org
<a class="page-header__cta btn btn-primary" href="org/new">
New org
</a>
</div>

View File

@@ -7,9 +7,8 @@
<i class="gf-form-input-icon fa fa-search"></i>
</label>
<div class="page-action-bar__spacer"></div>
<a class="btn btn-success" href="admin/users/create">
<i class="fa fa-plus"></i>
Add new user
<a class="btn btn-primary" href="admin/users/create">
New user
</a>
</div>

View File

@@ -29,7 +29,7 @@ class AlertRuleItem extends PureComponent<Props> {
'fa-pause': rule.state !== 'paused',
});
const ruleUrl = `${rule.url}?panelId=${rule.panelId}&fullscreen=true&edit=true&tab=alert`;
const ruleUrl = `${rule.url}?panelId=${rule.panelId}&fullscreen&edit&tab=alert`;
return (
<li className="alert-rule-item">

View File

@@ -18,7 +18,7 @@ const setup = (propOverrides?: object) => {
togglePauseAlertRule: jest.fn(),
stateFilter: '',
search: '',
isLoading: false
isLoading: false,
};
Object.assign(props, propOverrides);
@@ -147,9 +147,8 @@ describe('Functions', () => {
describe('Search query change', () => {
it('should set search query', () => {
const { instance } = setup();
const mockEvent = { target: { value: 'dashboard' } } as React.ChangeEvent<HTMLInputElement>;
instance.onSearchQueryChange(mockEvent);
instance.onSearchQueryChange('dashboard');
expect(instance.props.setSearchQuery).toHaveBeenCalledWith('dashboard');
});

View File

@@ -9,6 +9,7 @@ import { getNavModel } from 'app/core/selectors/navModel';
import { NavModel, StoreState, AlertRule } from 'app/types';
import { getAlertRulesAsync, setSearchQuery, togglePauseAlertRule } from './state/actions';
import { getAlertRuleItems, getSearchQuery } from './state/selectors';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
export interface Props {
navModel: NavModel;
@@ -69,8 +70,7 @@ export class AlertRuleList extends PureComponent<Props, any> {
});
};
onSearchQueryChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
const { value } = evt.target;
onSearchQueryChange = (value: string) => {
this.props.setSearchQuery(value);
};
@@ -78,7 +78,7 @@ export class AlertRuleList extends PureComponent<Props, any> {
this.props.togglePauseAlertRule(rule.id, { paused: rule.state !== 'paused' });
};
alertStateFilterOption = ({ text, value }: { text: string; value: string; }) => {
alertStateFilterOption = ({ text, value }: { text: string; value: string }) => {
return (
<option key={value} value={value}>
{text}
@@ -94,16 +94,13 @@ export class AlertRuleList extends PureComponent<Props, any> {
<Page.Contents isLoading={isLoading}>
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<label className="gf-form--has-input-icon gf-form--grow">
<input
type="text"
className="gf-form-input"
placeholder="Search alerts"
value={search}
onChange={this.onSearchQueryChange}
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
<FilterInput
labelClassName="gf-form--has-input-icon gf-form--grow"
inputClassName="gf-form-input"
placeholder="Search alerts"
value={search}
onChange={this.onSearchQueryChange}
/>
</div>
<div className="gf-form">
<label className="gf-form-label">States</label>
@@ -142,7 +139,7 @@ const mapStateToProps = (state: StoreState) => ({
alertRules: getAlertRuleItems(state.alertRules),
stateFilter: state.location.query.state,
search: getSearchQuery(state.alertRules),
isLoading: state.alertRules.isLoading
isLoading: state.alertRules.isLoading,
});
const mapDispatchToProps = {
@@ -152,4 +149,9 @@ const mapDispatchToProps = {
togglePauseAlertRule,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(AlertRuleList));
export default hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(AlertRuleList)
);

View File

@@ -140,7 +140,7 @@ export class AlertTabCtrl {
name: model.name,
iconClass: this.getNotificationIcon(model.type),
isDefault: false,
uid: model.uid
uid: model.uid,
});
// avoid duplicates using both id and uid to be backwards compatible.
@@ -157,8 +157,8 @@ export class AlertTabCtrl {
removeNotification(an) {
// remove notifiers refeered to by id and uid to support notifiers added
// before and after we added support for uid
_.remove(this.alert.notifications, n => n.uid === an.uid || n.id === an.id);
_.remove(this.alertNotifications, n => n.uid === an.uid || n.id === an.id);
_.remove(this.alert.notifications, n => n.uid === an.uid || n.id === an.id);
_.remove(this.alertNotifications, n => n.uid === an.uid || n.id === an.id);
}
initModel() {

View File

@@ -21,7 +21,7 @@ exports[`Render should render component 1`] = `
className="alert-rule-item__name"
>
<a
href="https://something.something.darkside?panelId=1&fullscreen=true&edit=true&tab=alert"
href="https://something.something.darkside?panelId=1&fullscreen&edit&tab=alert"
>
<Highlighter
highlightClassName="highlight-search-match"
@@ -73,7 +73,7 @@ exports[`Render should render component 1`] = `
</button>
<a
className="btn btn-small btn-inverse alert-list__btn width-2"
href="https://something.something.darkside?panelId=1&fullscreen=true&edit=true&tab=alert"
href="https://something.something.darkside?panelId=1&fullscreen&edit&tab=alert"
title="Edit alert rule"
>
<i

View File

@@ -13,20 +13,13 @@ exports[`Render should render alert rules 1`] = `
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form--has-input-icon gf-form--grow"
>
<input
className="gf-form-input"
onChange={[Function]}
placeholder="Search alerts"
type="text"
value=""
/>
<i
className="gf-form-input-icon fa fa-search"
/>
</label>
<ForwardRef
inputClassName="gf-form-input"
labelClassName="gf-form--has-input-icon gf-form--grow"
onChange={[Function]}
placeholder="Search alerts"
value=""
/>
</div>
<div
className="gf-form"
@@ -167,20 +160,13 @@ exports[`Render should render component 1`] = `
<div
className="gf-form gf-form--grow"
>
<label
className="gf-form--has-input-icon gf-form--grow"
>
<input
className="gf-form-input"
onChange={[Function]}
placeholder="Search alerts"
type="text"
value=""
/>
<i
className="gf-form-input-icon fa fa-search"
/>
</label>
<ForwardRef
inputClassName="gf-form-input"
labelClassName="gf-form--has-input-icon gf-form--grow"
onChange={[Function]}
placeholder="Search alerts"
value=""
/>
</div>
<div
className="gf-form"

View File

@@ -17,7 +17,7 @@
</div>
<div class="gf-form max-width-11">
<label class="gf-form-label width-5">For</label>
<input type="text" class="gf-form-input max-width-6" ng-model="ctrl.alert.for"
<input type="text" class="gf-form-input max-width-6 gf-form-input--has-help-icon" ng-model="ctrl.alert.for"
spellcheck='false' placeholder="5m">
<info-popover mode="right-absolute">
If an alert rule has a configured For and the query violates the configured

View File

@@ -68,7 +68,7 @@
</div>
<div class="gf-form-group gf-form-button-row">
<button type="submit" ng-click="ctrl.save()" class="btn btn-success width-7">Save</button>
<button type="submit" ng-click="ctrl.save()" class="btn btn-primary width-7">Save</button>
<button type="submit" ng-click="ctrl.testNotification()" class="btn btn-secondary width-7">Send Test</button>
<a href="alerting/notifications" class="btn btn-inverse">Back</a>
</div>

View File

@@ -7,9 +7,8 @@
<div class="page-action-bar__spacer">
</div>
<a href="alerting/notification/new" class="btn btn-success">
<i class="fa fa-plus"></i>
New Channel
<a href="alerting/notification/new" class="btn btn-primary">
New channel
</a>
</div>

View File

@@ -12,3 +12,4 @@ import './manage-dashboards';
import './teams/CreateTeamCtrl';
import './profile/all';
import './datasources/settings/HttpSettingsCtrl';
import './datasources/settings/TlsAuthSettingsCtrl';

View File

@@ -2,6 +2,7 @@ import angular from 'angular';
import _ from 'lodash';
import $ from 'jquery';
import coreModule from 'app/core/core_module';
import { DashboardModel } from 'app/features/dashboard/state';
export class AnnotationsEditorCtrl {
mode: any;
@@ -10,6 +11,7 @@ export class AnnotationsEditorCtrl {
currentAnnotation: any;
currentDatasource: any;
currentIsNew: any;
dashboard: DashboardModel;
annotationDefaults: any = {
name: '',
@@ -26,9 +28,10 @@ export class AnnotationsEditorCtrl {
constructor($scope, private datasourceSrv) {
$scope.ctrl = this;
this.dashboard = $scope.dashboard;
this.mode = 'list';
this.datasources = datasourceSrv.getAnnotationSources();
this.annotations = $scope.dashboard.annotations.list;
this.annotations = this.dashboard.annotations.list;
this.reset();
this.onColorChange = this.onColorChange.bind(this);
@@ -78,11 +81,13 @@ export class AnnotationsEditorCtrl {
this.annotations.push(this.currentAnnotation);
this.reset();
this.mode = 'list';
this.dashboard.updateSubmenuVisibility();
}
removeAnnotation(annotation) {
const index = _.indexOf(this.annotations, annotation);
this.annotations.splice(index, 1);
this.dashboard.updateSubmenuVisibility();
}
onColorChange(newColor) {

View File

@@ -9,7 +9,7 @@
<div ng-if="ctrl.mode === 'list'">
<div class="page-action-bar" ng-if="ctrl.annotations.length > 1">
<div class="page-action-bar__spacer"></div>
<a type="button" class="btn btn-success" ng-click="ctrl.setupNew();"><i class="fa fa-plus" ></i> New</a>
<a type="button" class="btn btn-primary" ng-click="ctrl.setupNew();"><i class="fa fa-plus" ></i> New</a>
</div>
<table class="filter-table filter-table--hover">
@@ -48,7 +48,7 @@
<div ng-if="ctrl.annotations.length === 1" class="p-t-2">
<div class="empty-list-cta">
<div class="empty-list-cta__title">There are no custom annotation queries added yet</div>
<a ng-click="ctrl.setupNew()" class="empty-list-cta__button btn btn-xlarge btn-success">
<a ng-click="ctrl.setupNew()" class="empty-list-cta__button btn btn-xlarge btn-primary">
<i class="gicon gicon-add-annotation"></i>
Add Annotation Query
</a>
@@ -105,8 +105,8 @@
<div class="gf-form">
<div class="gf-form-button-row p-y-0">
<button ng-show="ctrl.mode === 'new'" type="button" class="btn gf-form-button btn-success" ng-click="ctrl.add()">Add</button>
<button ng-show="ctrl.mode === 'edit'" type="button" class="btn btn-success pull-left" ng-click="ctrl.update()">Update</button>
<button ng-show="ctrl.mode === 'new'" type="button" class="btn gf-form-button btn-primary" ng-click="ctrl.add()">Add</button>
<button ng-show="ctrl.mode === 'edit'" type="button" class="btn btn-primary pull-left" ng-click="ctrl.update()">Update</button>
</div>
</div>
</div>

View File

@@ -26,7 +26,7 @@
</div>
<div class="gf-form-button-row">
<button type="submit" class="btn btn-success" ng-click="ctrl.save()">Save</button>
<button type="submit" class="btn btn-primary" ng-click="ctrl.save()">Save</button>
<button ng-if="ctrl.event.id" type="submit" class="btn btn-danger" ng-click="ctrl.delete()">Delete</button>
<a class="btn-text" ng-click="ctrl.close();">Cancel</a>
</div>

View File

@@ -8,11 +8,11 @@ const setup = (propOverrides?: object) => {
const props: Props = {
navModel: {
main: {
text: 'Configuration'
text: 'Configuration',
},
node: {
text: 'Api Keys'
}
text: 'Api Keys',
},
} as NavModel,
apiKeys: [] as ApiKey[],
searchQuery: '',
@@ -78,9 +78,8 @@ describe('Functions', () => {
describe('on search query change', () => {
it('should call setSearchQuery', () => {
const { instance } = setup();
const mockEvent = { target: { value: 'test' } };
instance.onSearchQueryChange(mockEvent);
instance.onSearchQueryChange('test');
expect(instance.props.setSearchQuery).toHaveBeenCalledWith('test');
});

View File

@@ -13,6 +13,7 @@ import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { DeleteButton } from '@grafana/ui';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
export interface Props {
navModel: NavModel;
@@ -59,8 +60,8 @@ export class ApiKeysPage extends PureComponent<Props, any> {
this.props.deleteApiKey(key.id);
}
onSearchQueryChange = evt => {
this.props.setSearchQuery(evt.target.value);
onSearchQueryChange = (value: string) => {
this.props.setSearchQuery(value);
};
onToggleAdding = () => {
@@ -107,7 +108,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
renderEmptyList() {
const { isAdding } = this.state;
return (
<div className="page-container page-body">
<>
{!isAdding && (
<EmptyListCTA
model={{
@@ -124,7 +125,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
/>
)}
{this.renderAddApiKeyForm()}
</div>
</>
);
}
@@ -169,7 +170,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
</span>
</div>
<div className="gf-form">
<button className="btn gf-form-btn btn-success">Add</button>
<button className="btn gf-form-btn btn-primary">Add</button>
</div>
</div>
</form>
@@ -183,24 +184,21 @@ export class ApiKeysPage extends PureComponent<Props, any> {
const { apiKeys, searchQuery } = this.props;
return (
<div className="page-container page-body">
<>
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<label className="gf-form--has-input-icon gf-form--grow">
<input
type="text"
className="gf-form-input"
placeholder="Search keys"
value={searchQuery}
onChange={this.onSearchQueryChange}
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
<FilterInput
labelClassName="gf-form--has-input-icon gf-form--grow"
inputClassName="gf-form-input"
placeholder="Search keys"
value={searchQuery}
onChange={this.onSearchQueryChange}
/>
</div>
<div className="page-action-bar__spacer" />
<button className="btn btn-success pull-right" onClick={this.onToggleAdding} disabled={isAdding}>
<i className="fa fa-plus" /> Add API Key
<button className="btn btn-primary pull-right" onClick={this.onToggleAdding} disabled={isAdding}>
Add API key
</button>
</div>
@@ -231,7 +229,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
</tbody>
) : null}
</table>
</div>
</>
);
}
@@ -241,13 +239,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
return (
<Page navModel={navModel}>
<Page.Contents isLoading={!hasFetched}>
{hasFetched && (
apiKeysCount > 0 ? (
this.renderApiKeyList()
) : (
this.renderEmptyList()
)
)}
{hasFetched && (apiKeysCount > 0 ? this.renderApiKeyList() : this.renderEmptyList())}
</Page.Contents>
</Page>
);
@@ -271,4 +263,9 @@ const mapDispatchToProps = {
addApiKey,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(ApiKeysPage));
export default hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(ApiKeysPage)
);

View File

@@ -35,118 +35,114 @@ exports[`Render should render CTA if there are no API keys 1`] = `
<PageContents
isLoading={false}
>
<div
className="page-container page-body"
>
<EmptyListCTA
model={
Object {
"buttonIcon": "fa fa-plus",
"buttonLink": "#",
"buttonTitle": " New API Key",
"onClick": [Function],
"proTip": "Remember you can provide view-only API access to other applications.",
"proTipLink": "",
"proTipLinkTitle": "",
"proTipTarget": "_blank",
"title": "You haven't added any API Keys yet.",
}
<EmptyListCTA
model={
Object {
"buttonIcon": "fa fa-plus",
"buttonLink": "#",
"buttonTitle": " New API Key",
"onClick": [Function],
"proTip": "Remember you can provide view-only API access to other applications.",
"proTipLink": "",
"proTipLinkTitle": "",
"proTipTarget": "_blank",
"title": "You haven't added any API Keys yet.",
}
/>
<Component
in={false}
}
/>
<Component
in={false}
>
<div
className="cta-form"
>
<div
className="cta-form"
<button
className="cta-form__close btn btn-transparent"
onClick={[Function]}
>
<button
className="cta-form__close btn btn-transparent"
onClick={[Function]}
>
<i
className="fa fa-close"
/>
</button>
<h5>
Add API Key
</h5>
<form
className="gf-form-group"
onSubmit={[Function]}
<i
className="fa fa-close"
/>
</button>
<h5>
Add API Key
</h5>
<form
className="gf-form-group"
onSubmit={[Function]}
>
<div
className="gf-form-inline"
>
<div
className="gf-form-inline"
className="gf-form max-width-21"
>
<div
className="gf-form max-width-21"
<span
className="gf-form-label"
>
<span
className="gf-form-label"
>
Key name
</span>
<input
className="gf-form-input"
Key name
</span>
<input
className="gf-form-input"
onChange={[Function]}
placeholder="Name"
type="text"
value=""
/>
</div>
<div
className="gf-form"
>
<span
className="gf-form-label"
>
Role
</span>
<span
className="gf-form-select-wrapper"
>
<select
className="gf-form-input gf-size-auto"
onChange={[Function]}
placeholder="Name"
type="text"
value=""
/>
</div>
<div
className="gf-form"
>
<span
className="gf-form-label"
value="Viewer"
>
Role
</span>
<span
className="gf-form-select-wrapper"
>
<select
className="gf-form-input gf-size-auto"
onChange={[Function]}
<option
key="Viewer"
label="Viewer"
value="Viewer"
>
<option
key="Viewer"
label="Viewer"
value="Viewer"
>
Viewer
</option>
<option
key="Editor"
label="Editor"
value="Editor"
>
Editor
</option>
<option
key="Admin"
label="Admin"
value="Admin"
>
Admin
</option>
</select>
</span>
</div>
<div
className="gf-form"
>
<button
className="btn gf-form-btn btn-success"
>
Add
</button>
</div>
Viewer
</option>
<option
key="Editor"
label="Editor"
value="Editor"
>
Editor
</option>
<option
key="Admin"
label="Admin"
value="Admin"
>
Admin
</option>
</select>
</span>
</div>
</form>
</div>
</Component>
</div>
<div
className="gf-form"
>
<button
className="btn gf-form-btn btn-primary"
>
Add
</button>
</div>
</div>
</form>
</div>
</Component>
</PageContents>
</Page>
`;

View File

@@ -1,27 +1,22 @@
import _ from 'lodash';
import angular from 'angular';
import coreModule from 'app/core/core_module';
import { DashboardModel } from 'app/features/dashboard/state';
export class AdHocFiltersCtrl {
segments: any;
variable: any;
dashboard: DashboardModel;
removeTagFilterSegment: any;
/** @ngInject */
constructor(
private uiSegmentSrv,
private datasourceSrv,
private $q,
private variableSrv,
$scope,
private $rootScope
) {
constructor(private uiSegmentSrv, private datasourceSrv, private $q, private variableSrv, $scope) {
this.removeTagFilterSegment = uiSegmentSrv.newSegment({
fake: true,
value: '-- remove filter --',
});
this.buildSegmentModel();
this.$rootScope.onAppEvent('template-variable-value-updated', this.buildSegmentModel.bind(this), $scope);
this.dashboard.events.on('template-variable-value-updated', this.buildSegmentModel.bind(this), $scope);
}
buildSegmentModel() {
@@ -171,6 +166,7 @@ export function adHocFiltersComponent() {
controllerAs: 'ctrl',
scope: {
variable: '=',
dashboard: '=',
},
};
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { shallow } from 'enzyme';
import { AddPanelWidget, Props } from './AddPanelWidget';
import { DashboardModel, PanelModel } from '../../state';
const setup = (propOverrides?: object) => {
const props: Props = {
dashboard: {} as DashboardModel,
panel: {} as PanelModel,
};
Object.assign(props, propOverrides);
return shallow(<AddPanelWidget {...props} />);
};
describe('Render', () => {
it('should render component', () => {
const wrapper = setup();
expect(wrapper).toMatchSnapshot();
});
});

View File

@@ -1,12 +1,20 @@
// Libraries
import React from 'react';
import _ from 'lodash';
// Utils
import config from 'app/core/config';
import { PanelModel } from '../../state/PanelModel';
import { DashboardModel } from '../../state/DashboardModel';
import store from 'app/core/store';
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
import { updateLocation } from 'app/core/actions';
// Store
import { store as reduxStore } from 'app/store/store';
import { updateLocation } from 'app/core/actions';
// Types
import { PanelModel } from '../../state';
import { DashboardModel } from '../../state';
import { LS_PANEL_COPY_KEY } from 'app/core/constants';
import { LocationUpdate } from 'app/types';
export interface Props {
panel: PanelModel;
@@ -46,6 +54,7 @@ export class AddPanelWidget extends React.Component<Props, State> {
copiedPanels.push(pluginCopy);
}
}
return _.sortBy(copiedPanels, 'sort');
}
@@ -54,28 +63,7 @@ export class AddPanelWidget extends React.Component<Props, State> {
this.props.dashboard.removePanel(this.props.dashboard.panels[0]);
}
copyButton(panel) {
return (
<button className="btn-inverse btn" onClick={() => this.onPasteCopiedPanel(panel)} title={panel.name}>
Paste copied Panel
</button>
);
}
moveToEdit(panel) {
reduxStore.dispatch(
updateLocation({
query: {
panelId: panel.id,
edit: true,
fullscreen: true,
},
partial: true,
})
);
}
onCreateNewPanel = () => {
onCreateNewPanel = (tab = 'queries') => {
const dashboard = this.props.dashboard;
const { gridPos } = this.props.panel;
@@ -88,7 +76,21 @@ export class AddPanelWidget extends React.Component<Props, State> {
dashboard.addPanel(newPanel);
dashboard.removePanel(this.props.panel);
this.moveToEdit(newPanel);
const location: LocationUpdate = {
query: {
panelId: newPanel.id,
edit: true,
fullscreen: true,
},
partial: true,
};
if (tab === 'visualization') {
location.query.tab = 'visualization';
location.query.openVizPicker = true;
}
reduxStore.dispatch(updateLocation(location));
};
onPasteCopiedPanel = panelPluginInfo => {
@@ -98,7 +100,12 @@ export class AddPanelWidget extends React.Component<Props, State> {
const newPanel: any = {
type: panelPluginInfo.id,
title: 'Panel Title',
gridPos: { x: gridPos.x, y: gridPos.y, w: gridPos.w, h: gridPos.h },
gridPos: {
x: gridPos.x,
y: gridPos.y,
w: panelPluginInfo.defaults.gridPos.w,
h: panelPluginInfo.defaults.gridPos.h,
},
};
// apply panel template / defaults
@@ -125,30 +132,52 @@ export class AddPanelWidget extends React.Component<Props, State> {
dashboard.removePanel(this.props.panel);
};
render() {
let addCopyButton;
renderOptionLink = (icon, text, onClick) => {
return (
<div>
<a href="#" onClick={onClick} className="add-panel-widget__link btn btn-inverse">
<div className="add-panel-widget__icon">
<i className={`gicon gicon-${icon}`} />
</div>
<span>{text}</span>
</a>
</div>
);
};
if (this.state.copiedPanelPlugins.length === 1) {
addCopyButton = this.copyButton(this.state.copiedPanelPlugins[0]);
}
render() {
const { copiedPanelPlugins } = this.state;
return (
<div className="panel-container add-panel-widget-container">
<div className="add-panel-widget">
<div className="add-panel-widget__header grid-drag-handle">
<i className="gicon gicon-add-panel" />
<span className="add-panel-widget__title">New Panel</span>
<button className="add-panel-widget__close" onClick={this.handleCloseAddPanel}>
<i className="fa fa-close" />
</button>
</div>
<div className="add-panel-widget__btn-container">
<button className="btn-success btn btn-large" onClick={this.onCreateNewPanel}>
Edit Panel
</button>
{addCopyButton}
<button className="btn-inverse btn" onClick={this.onCreateNewRow}>
Add Row
</button>
<div className="add-panel-widget__create">
{this.renderOptionLink('queries', 'Add Query', this.onCreateNewPanel)}
{this.renderOptionLink('visualization', 'Choose Visualization', () =>
this.onCreateNewPanel('visualization')
)}
</div>
<div className="add-panel-widget__actions">
<button className="btn btn-inverse add-panel-widget__action" onClick={this.onCreateNewRow}>
Convert to row
</button>
{copiedPanelPlugins.length === 1 && (
<button
className="btn btn-inverse add-panel-widget__action"
onClick={() => this.onPasteCopiedPanel(copiedPanelPlugins[0])}
>
Paste copied panel
</button>
)}
</div>
</div>
</div>
</div>

View File

@@ -14,10 +14,13 @@
align-items: center;
width: 100%;
cursor: move;
background: $page-header-bg;
box-shadow: $page-header-shadow;
border-bottom: 1px solid $page-header-border-color;
.gicon {
font-size: 30px;
margin-right: $spacer;
margin-right: $space-md;
}
&:hover {
@@ -26,6 +29,29 @@
}
}
.add-panel-widget__title {
font-size: $font-size-md;
font-weight: $font-weight-semi-bold;
margin-right: $space-xl;
}
.add-panel-widget__link {
margin: 0 $space-sm;
width: 154px;
}
.add-panel-widget__icon {
margin-bottom: $space-sm;
.gicon {
color: white;
height: 44px;
width: 53px;
position: relative;
left: 5px;
}
}
.add-panel-widget__close {
margin-left: auto;
background-color: transparent;
@@ -34,14 +60,25 @@
margin-right: -10px;
}
.add-panel-widget__create {
display: inherit;
margin-bottom: $space-lg;
// this is to have the big button appear centered
margin-top: 55px;
}
.add-panel-widget__actions {
display: inherit;
}
.add-panel-widget__action {
margin: 0 $space-xs;
}
.add-panel-widget__btn-container {
height: 100%;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
flex-direction: column;
.btn {
margin-bottom: 10px;
}
}

View File

@@ -0,0 +1,86 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Render should render component 1`] = `
<div
className="panel-container add-panel-widget-container"
>
<div
className="add-panel-widget"
>
<div
className="add-panel-widget__header grid-drag-handle"
>
<i
className="gicon gicon-add-panel"
/>
<span
className="add-panel-widget__title"
>
New Panel
</span>
<button
className="add-panel-widget__close"
onClick={[Function]}
>
<i
className="fa fa-close"
/>
</button>
</div>
<div
className="add-panel-widget__btn-container"
>
<div
className="add-panel-widget__create"
>
<div>
<a
className="add-panel-widget__link btn btn-inverse"
href="#"
onClick={[Function]}
>
<div
className="add-panel-widget__icon"
>
<i
className="gicon gicon-queries"
/>
</div>
<span>
Add Query
</span>
</a>
</div>
<div>
<a
className="add-panel-widget__link btn btn-inverse"
href="#"
onClick={[Function]}
>
<div
className="add-panel-widget__icon"
>
<i
className="gicon gicon-visualization"
/>
</div>
<span>
Choose Visualization
</span>
</a>
</div>
</div>
<div
className="add-panel-widget__actions"
>
<button
className="btn btn-inverse add-panel-widget__action"
onClick={[Function]}
>
Convert to row
</button>
</div>
</div>
</div>
</div>
`;

View File

@@ -12,7 +12,7 @@
</gf-form-switch>
<div class="gf-form-button-row">
<button type="button" class="btn gf-form-btn width-10 btn-success" ng-click="ctrl.saveDashboardAsFile()">
<button type="button" class="btn gf-form-btn width-10 btn-primary" ng-click="ctrl.saveDashboardAsFile()">
<i class="fa fa-save"></i> Save to file
</button>
<button type="button" class="btn gf-form-btn width-10 btn-secondary" ng-click="ctrl.viewJson()">

View File

@@ -1,5 +1,6 @@
import angular from 'angular';
import _ from 'lodash';
import { DashboardModel } from 'app/features/dashboard/state';
export let iconMap = {
'external link': 'fa-external-link',
@@ -12,7 +13,7 @@ export let iconMap = {
};
export class DashLinksEditorCtrl {
dashboard: any;
dashboard: DashboardModel;
iconMap: any;
mode: any;
link: any;
@@ -40,6 +41,7 @@ export class DashLinksEditorCtrl {
addLink() {
this.dashboard.links.push(this.link);
this.mode = 'list';
this.dashboard.updateSubmenuVisibility();
}
editLink(link) {

View File

@@ -10,7 +10,7 @@
<div class="empty-list-cta__title">
There are no dashboard links added yet
</div>
<a ng-click="ctrl.setupNew()" class="empty-list-cta__button btn btn-xlarge btn-success">
<a ng-click="ctrl.setupNew()" class="empty-list-cta__button btn btn-xlarge btn-primary">
<i class="gicon gicon-add-link"></i>
Add Dashboard Link
</a>
@@ -26,7 +26,7 @@
<div ng-if="ctrl.dashboard.links.length > 0">
<div class="page-action-bar">
<div class="page-action-bar__spacer"></div>
<a type="button" class="btn btn-success" ng-click="ctrl.setupNew()">
<a type="button" class="btn btn-primary" ng-click="ctrl.setupNew()">
<i class="fa fa-plus"></i> New</a>
</div>
<table class="filter-table filter-table--hover">
@@ -126,10 +126,10 @@
</div>
</div>
</div>
<button class="btn btn-success" ng-if="ctrl.mode == 'new'" ng-click="ctrl.addLink()">
<button class="btn btn-primary" ng-if="ctrl.mode == 'new'" ng-click="ctrl.addLink()">
Add
</button>
<button class="btn btn-success" ng-if="ctrl.mode == 'edit'" ng-click="ctrl.saveLink()">
<button class="btn btn-primary" ng-if="ctrl.mode == 'edit'" ng-click="ctrl.saveLink()">
Update
</button>
</div>

View File

@@ -0,0 +1,273 @@
// Libaries
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
// Utils & Services
import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
import { appEvents } from 'app/core/app_events';
import { PlaylistSrv } from 'app/features/playlist/playlist_srv';
// Components
import { DashNavButton } from './DashNavButton';
import { Tooltip } from '@grafana/ui';
// State
import { updateLocation } from 'app/core/actions';
// Types
import { DashboardModel } from '../../state';
export interface Props {
dashboard: DashboardModel;
editview: string;
isEditing: boolean;
isFullscreen: boolean;
$injector: any;
updateLocation: typeof updateLocation;
onAddPanel: () => void;
}
export class DashNav extends PureComponent<Props> {
timePickerEl: HTMLElement;
timepickerCmp: AngularComponent;
playlistSrv: PlaylistSrv;
constructor(props: Props) {
super(props);
this.playlistSrv = this.props.$injector.get('playlistSrv');
}
componentDidMount() {
const loader = getAngularLoader();
const template =
'<gf-time-picker class="gf-timepicker-nav" dashboard="dashboard" ng-if="!dashboard.timepicker.hidden" />';
const scopeProps = { dashboard: this.props.dashboard };
this.timepickerCmp = loader.load(this.timePickerEl, scopeProps, template);
}
componentWillUnmount() {
if (this.timepickerCmp) {
this.timepickerCmp.destroy();
}
}
onOpenSearch = () => {
appEvents.emit('show-dash-search');
};
onClose = () => {
if (this.props.editview) {
this.props.updateLocation({
query: { editview: null },
partial: true,
});
} else {
this.props.updateLocation({
query: { panelId: null, edit: null, fullscreen: null, tab: null },
partial: true,
});
}
};
onToggleTVMode = () => {
appEvents.emit('toggle-kiosk-mode');
};
onSave = () => {
const { $injector } = this.props;
const dashboardSrv = $injector.get('dashboardSrv');
dashboardSrv.saveDashboard();
};
onOpenSettings = () => {
this.props.updateLocation({
query: { editview: 'settings' },
partial: true,
});
};
onStarDashboard = () => {
const { dashboard, $injector } = this.props;
const dashboardSrv = $injector.get('dashboardSrv');
dashboardSrv.starDashboard(dashboard.id, dashboard.meta.isStarred).then(newState => {
dashboard.meta.isStarred = newState;
this.forceUpdate();
});
};
onPlaylistPrev = () => {
this.playlistSrv.prev();
};
onPlaylistNext = () => {
this.playlistSrv.next();
};
onPlaylistStop = () => {
this.playlistSrv.stop();
this.forceUpdate();
};
onOpenShare = () => {
const $rootScope = this.props.$injector.get('$rootScope');
const modalScope = $rootScope.$new();
modalScope.tabIndex = 0;
modalScope.dashboard = this.props.dashboard;
appEvents.emit('show-modal', {
src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: modalScope,
});
};
renderDashboardTitleSearchButton() {
const { dashboard } = this.props;
const folderTitle = dashboard.meta.folderTitle;
const haveFolder = dashboard.meta.folderId > 0;
return (
<>
<div>
<a className="navbar-page-btn" onClick={this.onOpenSearch}>
{!this.isInFullscreenOrSettings && <i className="gicon gicon-dashboard" />}
{haveFolder && <span className="navbar-page-btn--folder">{folderTitle} / </span>}
{dashboard.title}
<i className="fa fa-caret-down" />
</a>
</div>
<div className="navbar__spacer" />
</>
);
}
get isInFullscreenOrSettings() {
return this.props.editview || this.props.isFullscreen;
}
renderBackButton() {
return (
<div className="navbar-edit">
<Tooltip content="Go back (Esc)">
<button className="navbar-edit__back-btn" onClick={this.onClose}>
<i className="fa fa-arrow-left" />
</button>
</Tooltip>
</div>
);
}
render() {
const { dashboard, onAddPanel } = this.props;
const { canStar, canSave, canShare, showSettings, isStarred } = dashboard.meta;
const { snapshot } = dashboard;
const snapshotUrl = snapshot && snapshot.originalUrl;
return (
<div className="navbar">
{this.isInFullscreenOrSettings && this.renderBackButton()}
{this.renderDashboardTitleSearchButton()}
{this.playlistSrv.isPlaying && (
<div className="navbar-buttons navbar-buttons--playlist">
<DashNavButton
tooltip="Go to previous dashboard"
classSuffix="tight"
icon="fa fa-step-backward"
onClick={this.onPlaylistPrev}
/>
<DashNavButton
tooltip="Stop playlist"
classSuffix="tight"
icon="fa fa-stop"
onClick={this.onPlaylistStop}
/>
<DashNavButton
tooltip="Go to next dashboard"
classSuffix="tight"
icon="fa fa-forward"
onClick={this.onPlaylistNext}
/>
</div>
)}
<div className="navbar-buttons navbar-buttons--actions">
{canSave && (
<DashNavButton
tooltip="Add panel"
classSuffix="add-panel"
icon="gicon gicon-add-panel"
onClick={onAddPanel}
/>
)}
{canStar && (
<DashNavButton
tooltip="Mark as favorite"
classSuffix="star"
icon={`${isStarred ? 'fa fa-star' : 'fa fa-star-o'}`}
onClick={this.onStarDashboard}
/>
)}
{canShare && (
<DashNavButton
tooltip="Share dashboard"
classSuffix="share"
icon="fa fa-share-square-o"
onClick={this.onOpenShare}
/>
)}
{canSave && (
<DashNavButton tooltip="Save dashboard" classSuffix="save" icon="fa fa-save" onClick={this.onSave} />
)}
{snapshotUrl && (
<DashNavButton
tooltip="Open original dashboard"
classSuffix="snapshot-origin"
icon="fa fa-link"
href={snapshotUrl}
/>
)}
{showSettings && (
<DashNavButton
tooltip="Dashboard settings"
classSuffix="settings"
icon="fa fa-cog"
onClick={this.onOpenSettings}
/>
)}
</div>
<div className="navbar-buttons navbar-buttons--tv">
<DashNavButton
tooltip="Cycle view mode"
classSuffix="tv"
icon="fa fa-desktop"
onClick={this.onToggleTVMode}
/>
</div>
<div className="gf-timepicker-nav" ref={element => (this.timePickerEl = element)} />
</div>
);
}
}
const mapStateToProps = () => ({});
const mapDispatchToProps = {
updateLocation,
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(DashNav);

View File

@@ -0,0 +1,33 @@
// Libraries
import React, { FunctionComponent } from 'react';
// Components
import { Tooltip } from '@grafana/ui';
interface Props {
icon: string;
tooltip: string;
classSuffix: string;
onClick?: () => void;
href?: string;
}
export const DashNavButton: FunctionComponent<Props> = ({ icon, tooltip, classSuffix, onClick, href }) => {
if (onClick) {
return (
<Tooltip content={tooltip}>
<button className={`btn navbar-button navbar-button--${classSuffix}`} onClick={onClick}>
<i className={icon} />
</button>
</Tooltip>
);
}
return (
<Tooltip content={tooltip}>
<a className={`btn navbar-button navbar-button--${classSuffix}`} href={href}>
<i className={icon} />
</a>
</Tooltip>
);
};

View File

@@ -1,119 +0,0 @@
import moment from 'moment';
import angular from 'angular';
import { appEvents, NavModel } from 'app/core/core';
import { DashboardModel } from '../../state/DashboardModel';
export class DashNavCtrl {
dashboard: DashboardModel;
navModel: NavModel;
titleTooltip: string;
/** @ngInject */
constructor(private $scope, private dashboardSrv, private $location, public playlistSrv) {
appEvents.on('save-dashboard', this.saveDashboard.bind(this), $scope);
if (this.dashboard.meta.isSnapshot) {
const meta = this.dashboard.meta;
this.titleTooltip = 'Created: &nbsp;' + moment(meta.created).calendar();
if (meta.expires) {
this.titleTooltip += '<br>Expires: &nbsp;' + moment(meta.expires).fromNow() + '<br>';
}
}
}
toggleSettings() {
const search = this.$location.search();
if (search.editview) {
delete search.editview;
} else {
search.editview = 'settings';
}
this.$location.search(search);
}
toggleViewMode() {
appEvents.emit('toggle-kiosk-mode');
}
close() {
const search = this.$location.search();
if (search.editview) {
delete search.editview;
} else if (search.fullscreen) {
delete search.fullscreen;
delete search.edit;
delete search.tab;
delete search.panelId;
}
this.$location.search(search);
}
starDashboard() {
this.dashboardSrv.starDashboard(this.dashboard.id, this.dashboard.meta.isStarred).then(newState => {
this.dashboard.meta.isStarred = newState;
});
}
shareDashboard(tabIndex) {
const modalScope = this.$scope.$new();
modalScope.tabIndex = tabIndex;
modalScope.dashboard = this.dashboard;
appEvents.emit('show-modal', {
src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: modalScope,
});
}
hideTooltip(evt) {
angular.element(evt.currentTarget).tooltip('hide');
}
saveDashboard() {
return this.dashboardSrv.saveDashboard();
}
showSearch() {
if (this.dashboard.meta.fullscreen) {
this.close();
return;
}
appEvents.emit('show-dash-search');
}
addPanel() {
appEvents.emit('dash-scroll', { animate: true, evt: 0 });
if (this.dashboard.panels.length > 0 && this.dashboard.panels[0].type === 'add-panel') {
return; // Return if the "Add panel" exists already
}
this.dashboard.addPanel({
type: 'add-panel',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
title: 'Panel Title',
});
}
navItemClicked(navItem, evt) {
if (navItem.clickHandler) {
navItem.clickHandler();
evt.preventDefault();
}
}
}
export function dashNavDirective() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/components/DashNav/template.html',
controller: DashNavCtrl,
bindToController: true,
controllerAs: 'ctrl',
transclude: true,
scope: { dashboard: '=' },
};
}
angular.module('grafana.directives').directive('dashnav', dashNavDirective);

View File

@@ -1 +1,2 @@
export { DashNavCtrl } from './DashNavCtrl';
import DashNav from './DashNav';
export { DashNav };

View File

@@ -1,61 +0,0 @@
<div class="navbar">
<div>
<a class="navbar-page-btn" ng-click="ctrl.showSearch()">
<i class="gicon gicon-dashboard"></i>
<span ng-if="ctrl.dashboard.meta.folderId > 0" class="navbar-page-btn--folder">{{ctrl.dashboard.meta.folderTitle}} / </span>{{ctrl.dashboard.title}}
<i class="fa fa-caret-down"></i>
</a>
</div>
<div class="navbar__spacer"></div>
<div class="navbar-buttons navbar-buttons--playlist" ng-if="ctrl.playlistSrv.isPlaying">
<a class="navbar-button navbar-button--tight" ng-click="ctrl.playlistSrv.prev()"><i class="fa fa-step-backward"></i></a>
<a class="navbar-button navbar-button--tight" ng-click="ctrl.playlistSrv.stop()"><i class="fa fa-stop"></i></a>
<a class="navbar-button navbar-button--tight" ng-click="ctrl.playlistSrv.next()"><i class="fa fa-step-forward"></i></a>
</div>
<div class="navbar-buttons navbar-buttons--actions">
<button class="btn navbar-button navbar-button--add-panel" ng-show="::ctrl.dashboard.meta.canSave" bs-tooltip="'Add panel'" data-placement="bottom" ng-click="ctrl.addPanel()">
<i class="gicon gicon-add-panel"></i>
</button>
<button class="btn navbar-button navbar-button--star" ng-show="::ctrl.dashboard.meta.canStar" ng-click="ctrl.starDashboard()" bs-tooltip="'Mark as favorite'" data-placement="bottom">
<i class="fa" ng-class="{'fa-star-o': !ctrl.dashboard.meta.isStarred, 'fa-star': ctrl.dashboard.meta.isStarred}"></i>
</button>
<button class="btn navbar-button navbar-button--share" ng-show="::ctrl.dashboard.meta.canShare" ng-click="ctrl.shareDashboard(0)" bs-tooltip="'Share dashboard'" data-placement="bottom">
<i class="fa fa-share-square-o"></i></a>
</button>
<button class="btn navbar-button navbar-button--save" ng-show="ctrl.dashboard.meta.canSave" ng-click="ctrl.saveDashboard()" bs-tooltip="'Save dashboard <br> CTRL+S'" data-placement="bottom">
<i class="fa fa-save"></i>
</button>
<a class="btn navbar-button navbar-button--snapshot-origin" ng-if="::ctrl.dashboard.snapshot.originalUrl" href="{{ctrl.dashboard.snapshot.originalUrl}}" bs-tooltip="'Open original dashboard'" data-placement="bottom">
<i class="fa fa-link"></i>
</a>
<button class="btn navbar-button navbar-button--settings" ng-click="ctrl.toggleSettings()" bs-tooltip="'Dashboard Settings'" data-placement="bottom" ng-show="ctrl.dashboard.meta.showSettings">
<i class="fa fa-cog"></i>
</button>
</div>
<div class="navbar-buttons navbar-buttons--tv">
<button class="btn navbar-button navbar-button--tv" ng-click="ctrl.toggleViewMode()" bs-tooltip="'Cycle view mode'" data-placement="bottom">
<i class="fa fa-desktop"></i>
</button>
</div>
<gf-time-picker class="gf-timepicker-nav" dashboard="ctrl.dashboard" ng-if="!ctrl.dashboard.timepicker.hidden"></gf-time-picker>
<div class="navbar-buttons navbar-buttons--close">
<button class="btn navbar-button navbar-button--primary" ng-click="ctrl.close()" bs-tooltip="'Back to dashboard'" data-placement="bottom">
<i class="fa fa-reply"></i>
</button>
</div>
</div>
<dashboard-search></dashboard-search>

View File

@@ -76,8 +76,8 @@ export class DashboardPermissions extends PureComponent<Props, State> {
</div>
</Tooltip>
<div className="page-action-bar__spacer" />
<button className="btn btn-success pull-right" onClick={this.onOpenAddPermissions} disabled={isAdding}>
<i className="fa fa-plus" /> Add Permission
<button className="btn btn-primary pull-right" onClick={this.onOpenAddPermissions} disabled={isAdding}>
Add Permission
</button>
</div>
</div>

View File

@@ -9,6 +9,7 @@ describe('DashboardRow', () => {
beforeEach(() => {
dashboardMock = {
toggleRow: jest.fn(),
on: jest.fn(),
meta: {
canEdit: true,
},

View File

@@ -18,16 +18,16 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
collapsed: this.props.panel.collapsed,
};
appEvents.on('template-variable-value-updated', this.onVariableUpdated);
this.props.dashboard.on('template-variable-value-updated', this.onVariableUpdated);
}
componentWillUnmount() {
appEvents.off('template-variable-value-updated', this.onVariableUpdated);
this.props.dashboard.off('template-variable-value-updated', this.onVariableUpdated);
}
onVariableUpdated = () => {
this.forceUpdate();
}
};
onToggle = () => {
this.props.dashboard.toggleRow(this.props.panel);
@@ -35,12 +35,12 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
this.setState(prevState => {
return { collapsed: !prevState.collapsed };
});
}
};
onUpdate = () => {
this.props.dashboard.processRepeats();
this.forceUpdate();
}
};
onOpenSettings = () => {
appEvents.emit('show-modal', {
@@ -51,7 +51,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
onUpdated: this.onUpdate,
},
});
}
};
onDelete = () => {
appEvents.emit('confirm-modal', {
@@ -66,7 +66,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
this.props.dashboard.removeRow(this.props.panel, false);
},
});
}
};
render() {
const classes = classNames({

View File

@@ -0,0 +1,36 @@
// Libaries
import React, { PureComponent } from 'react';
// Utils & Services
import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
// Types
import { DashboardModel } from '../../state/DashboardModel';
export interface Props {
dashboard: DashboardModel | null;
}
export class DashboardSettings extends PureComponent<Props> {
element: HTMLElement;
angularCmp: AngularComponent;
componentDidMount() {
const loader = getAngularLoader();
const template = '<dashboard-settings dashboard="dashboard" class="dashboard-settings" />';
const scopeProps = { dashboard: this.props.dashboard };
this.angularCmp = loader.load(this.element, scopeProps, template);
}
componentWillUnmount() {
if (this.angularCmp) {
this.angularCmp.destroy();
}
}
render() {
return <div className="panel-height-helper" ref={element => (this.element = element)} />;
}
}

View File

@@ -38,7 +38,7 @@ export class SettingsCtrl {
});
});
this.canSaveAs = this.dashboard.meta.canEdit && contextSrv.hasEditPermissionInFolders;
this.canSaveAs = contextSrv.hasEditPermissionInFolders;
this.canSave = this.dashboard.meta.canSave;
this.canDelete = this.dashboard.meta.canSave;

View File

@@ -1 +1,2 @@
export { SettingsCtrl } from './SettingsCtrl';
export { DashboardSettings } from './DashboardSettings';

View File

@@ -10,17 +10,12 @@
</a>
<div class="dashboard-settings__aside-actions">
<button class="btn btn-success" ng-click="ctrl.saveDashboard()" ng-show="ctrl.canSave">
<i class="fa fa-save"></i> Save
<button class="btn btn-primary" ng-click="ctrl.saveDashboard()" ng-show="ctrl.canSave">
Save
</button>
<button class="btn btn-inverse" ng-click="ctrl.openSaveAsModal()" ng-show="ctrl.canSaveAs">
<i class="fa fa-copy"></i>
Save As...
</button>
<button class="btn btn-danger" ng-click="ctrl.deleteDashboard()" ng-show="ctrl.canDelete">
<i class="fa fa-trash"></i>
Delete
</button>
</div>
</aside>
@@ -72,6 +67,11 @@
<select ng-model="ctrl.dashboard.graphTooltip" class='gf-form-input' ng-options="f.value as f.text for f in [{value: 0, text: 'Default'}, {value: 1, text: 'Shared crosshair'},{value: 2, text: 'Shared Tooltip'}]"></select>
</div>
</div>
<div class="gf-form-button-row">
<button class="btn btn-danger" ng-click="ctrl.deleteDashboard()" ng-show="ctrl.canDelete">
Delete Dashboard
</button>
</div>
</div>
<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'annotations'" ng-include="'public/app/features/annotations/partials/editor.html'">
@@ -100,8 +100,8 @@
</div>
<div class="gf-form-button-row">
<button class="btn btn-success" ng-click="ctrl.saveDashboardJson()" ng-show="ctrl.canSave">
<i class="fa fa-save"></i> Save Changes
<button class="btn btn-primary" ng-click="ctrl.saveDashboardJson()" ng-show="ctrl.canSave">
Save Changes
</button>
</div>
</div>
@@ -128,7 +128,7 @@
<div class="dashboard-settings__content" ng-if="ctrl.viewId === 'make_editable'">
<h3 class="dashboard-settings__header">Make Editable</h3>
<button class="btn btn-success" ng-click="ctrl.makeEditable()">
<button class="btn btn-primary" ng-click="ctrl.makeEditable()">
Make Editable
</button>
</div>

View File

@@ -29,7 +29,7 @@
</div>
<div class="gf-form-button-row text-center">
<a class="btn btn-success" ng-click="ctrl.export();">Export</a>
<a class="btn btn-primary" ng-click="ctrl.export();">Export</a>
<a class="btn-text" ng-click="ctrl.dismiss();">Cancel</a>
</div>
</div>

View File

@@ -22,7 +22,7 @@
</div>
<div class="gf-form-button-row">
<button type="submit" class="btn btn-success" ng-click="ctrl.update()">Update</button>
<button type="submit" class="btn btn-primary" ng-click="ctrl.update()">Update</button>
<button type="button" class="btn btn-inverse" ng-click="ctrl.dismiss()">Cancel</button>
</div>
</div>

View File

@@ -16,23 +16,25 @@ const template = `
<form name="ctrl.saveForm" class="modal-content" novalidate>
<div class="p-t-2">
<div class="gf-form">
<label class="gf-form-label width-7">New name</label>
<label class="gf-form-label width-8">New name</label>
<input type="text" class="gf-form-input" ng-model="ctrl.clone.title" give-focus="true" required>
</div>
<div class="gf-form">
<folder-picker initial-folder-id="ctrl.folderId"
<folder-picker initial-folder-id="ctrl.folderId"
on-change="ctrl.onFolderChange($folder)"
enter-folder-creation="ctrl.onEnterFolderCreation()"
exit-folder-creation="ctrl.onExitFolderCreation()"
enable-create-new="true"
label-class="width-7"
label-class="width-8"
dashboard-id="ctrl.clone.id">
</folder-picker>
</folder-picker>
<div class="gf-form-inline">
<gf-form-switch class="gf-form" label="Copy tags" label-class="width-8" checked="ctrl.copyTags">
</gf-form-switch>
</div>
</div>
<div class="gf-form-button-row text-center">
<button type="submit" class="btn btn-success" ng-click="ctrl.save()" ng-disabled="!ctrl.isValidFolderSelection">Save</button>
<button type="submit" class="btn btn-primary" ng-click="ctrl.save()" ng-disabled="!ctrl.isValidFolderSelection">Save</button>
<a class="btn-text" ng-click="ctrl.dismiss();">Cancel</a>
</div>
</form>
@@ -44,6 +46,7 @@ export class SaveDashboardAsModalCtrl {
folderId: any;
dismiss: () => void;
isValidFolderSelection = true;
copyTags: boolean;
/** @ngInject */
constructor(private dashboardSrv) {
@@ -55,6 +58,7 @@ export class SaveDashboardAsModalCtrl {
this.clone.editable = true;
this.clone.hideControls = false;
this.folderId = dashboard.meta.folderId;
this.copyTags = false;
// remove alerts if source dashboard is already persisted
// do not want to create alert dupes
@@ -71,6 +75,10 @@ export class SaveDashboardAsModalCtrl {
}
save() {
if (!this.copyTags) {
this.clone.tags = [];
}
return this.dashboardSrv.save(this.clone, { folderId: this.folderId }).then(this.dismiss);
}

View File

@@ -52,8 +52,8 @@ const template = `
<button
id="saveBtn"
type="submit"
class="btn btn-success"
ng-class="{'btn-success--processing': ctrl.isSaving}"
class="btn btn-primary"
ng-class="{'btn-primary--processing': ctrl.isSaving}"
ng-disabled="ctrl.saveForm.$invalid || ctrl.isSaving"
>
<span ng-if="!ctrl.isSaving">Save</span>

View File

@@ -26,7 +26,7 @@ const template = `
<code-editor content="ctrl.dashboardJson" data-mode="json" data-max-lines=15></code-editor>
</div>
<div class="gf-form-button-row">
<button class="btn btn-success" clipboard-button="ctrl.getJsonForClipboard()">
<button class="btn btn-primary" clipboard-button="ctrl.getJsonForClipboard()">
<i class="fa fa-clipboard"></i>&nbsp;Copy JSON to Clipboard
</button>
<button class="btn btn-secondary" clipboard-button="ctrl.save()">

View File

@@ -1,2 +1,3 @@
export { SaveDashboardAsModalCtrl } from './SaveDashboardAsModalCtrl';
export { SaveDashboardModalCtrl } from './SaveDashboardModalCtrl';
export { SaveProvisionedDashboardModalCtrl } from './SaveProvisionedDashboardModalCtrl';

View File

@@ -163,7 +163,7 @@
</div>
<div ng-if="step === 1" class="gf-form-button-row">
<button class="btn gf-form-btn width-10 btn-success" ng-click="createSnapshot()" ng-disabled="loading">
<button class="btn gf-form-btn width-10 btn-primary" ng-click="createSnapshot()" ng-disabled="loading">
<i class="fa fa-save"></i>
Local Snapshot
</button>

View File

@@ -0,0 +1,36 @@
// Libaries
import React, { PureComponent } from 'react';
// Utils & Services
import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
// Types
import { DashboardModel } from '../../state/DashboardModel';
export interface Props {
dashboard: DashboardModel | null;
}
export class SubMenu extends PureComponent<Props> {
element: HTMLElement;
angularCmp: AngularComponent;
componentDidMount() {
const loader = getAngularLoader();
const template = '<dashboard-submenu dashboard="dashboard" />';
const scopeProps = { dashboard: this.props.dashboard };
this.angularCmp = loader.load(this.element, scopeProps, template);
}
componentWillUnmount() {
if (this.angularCmp) {
this.angularCmp.destroy();
}
}
render() {
return <div ref={element => (this.element = element)} />;
}
}

View File

@@ -1 +1,2 @@
export { SubMenuCtrl } from './SubMenuCtrl';
export { SubMenu } from './SubMenu';

View File

@@ -4,10 +4,10 @@
<label class="gf-form-label template-variable" ng-hide="variable.hide === 1">
{{variable.label || variable.name}}
</label>
<value-select-dropdown ng-if="variable.type !== 'adhoc' && variable.type !== 'textbox'" variable="variable" on-updated="ctrl.variableUpdated(variable)"></value-select-dropdown>
<value-select-dropdown ng-if="variable.type !== 'adhoc' && variable.type !== 'textbox'" dashboard="ctrl.dashboard" variable="variable" on-updated="ctrl.variableUpdated(variable)"></value-select-dropdown>
<input type="text" ng-if="variable.type === 'textbox'" ng-model="variable.query" class="gf-form-input width-12" ng-blur="variable.current.value != variable.query && variable.updateOptions() && ctrl.variableUpdated(variable);" ng-keydown="$event.keyCode === 13 && variable.current.value != variable.query && variable.updateOptions() && ctrl.variableUpdated(variable);" ></input>
</div>
<ad-hoc-filters ng-if="variable.type === 'adhoc'" variable="variable"></ad-hoc-filters>
<ad-hoc-filters ng-if="variable.type === 'adhoc'" variable="variable" dashboard="ctrl.dashboard"></ad-hoc-filters>
</div>
<div ng-if="ctrl.dashboard.annotations.list.length > 0">

View File

@@ -48,7 +48,7 @@
<input type="text" class="gf-form-input input-large" ng-model="ctrl.editTimeRaw.from" input-datetime>
</div>
<div class="gf-form">
<button class="btn gf-form-btn btn-primary" type="button" ng-click="openFromPicker=!openFromPicker">
<button class="btn gf-form-btn btn-secondary" type="button" ng-click="openFromPicker=!openFromPicker">
<i class="fa fa-calendar"></i>
</button>
</div>
@@ -65,7 +65,7 @@
<input type="text" class="gf-form-input input-large" ng-model="ctrl.editTimeRaw.to" input-datetime>
</div>
<div class="gf-form">
<button class="btn gf-form-btn btn-primary" type="button" ng-click="openToPicker=!openToPicker">
<button class="btn gf-form-btn btn-secondary" type="button" ng-click="openToPicker=!openToPicker">
<i class="fa fa-calendar"></i>
</button>
</div>
@@ -81,7 +81,7 @@
<select ng-model="ctrl.refresh.value" class="gf-form-input input-medium" ng-options="f.value as f.text for f in ctrl.refresh.options"></select>
</div>
<div class="gf-form">
<button type="submit" class="btn gf-form-btn btn-secondary" ng-click="ctrl.applyCustom();" ng-disabled="!timeForm.$valid">Apply</button>
<button type="submit" class="btn gf-form-btn btn-primary" ng-click="ctrl.applyCustom();" ng-disabled="!timeForm.$valid">Apply</button>
</div>
</div>
</form>

View File

@@ -20,7 +20,7 @@ const template = `
</div>
<div class="confirm-modal-buttons">
<button type="button" class="btn btn-success" ng-click="ctrl.save()">Save</button>
<button type="button" class="btn btn-primary" ng-click="ctrl.save()">Save</button>
<button type="button" class="btn btn-danger" ng-click="ctrl.discard()">Discard</button>
<button type="button" class="btn btn-inverse" ng-click="ctrl.dismiss()">Cancel</button>
</div>

View File

@@ -64,7 +64,7 @@
Show more versions
</button>
<button type="button"
class="btn btn-success"
class="btn btn-primary"
ng-if="ctrl.revisions.length > 1"
ng-disabled="!ctrl.canCompare"
ng-click="ctrl.getDiff(ctrl.diff)"

View File

@@ -1,156 +0,0 @@
// Utils
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import coreModule from 'app/core/core_module';
import { removePanel } from 'app/features/dashboard/utils/panel';
// Services
import { AnnotationsSrv } from '../../annotations/annotations_srv';
// Types
import { DashboardModel } from '../state/DashboardModel';
export class DashboardCtrl {
dashboard: DashboardModel;
dashboardViewState: any;
loadedFallbackDashboard: boolean;
editTab: number;
/** @ngInject */
constructor(
private $scope,
private keybindingSrv,
private timeSrv,
private variableSrv,
private dashboardSrv,
private unsavedChangesSrv,
private dashboardViewStateSrv,
private annotationsSrv: AnnotationsSrv,
public playlistSrv
) {
// temp hack due to way dashboards are loaded
// can't use controllerAs on route yet
$scope.ctrl = this;
// TODO: break out settings view to separate view & controller
this.editTab = 0;
// funcs called from React component bindings and needs this binding
this.getPanelContainer = this.getPanelContainer.bind(this);
}
setupDashboard(data) {
try {
this.setupDashboardInternal(data);
} catch (err) {
this.onInitFailed(err, 'Dashboard init failed', true);
}
}
setupDashboardInternal(data) {
const dashboard = this.dashboardSrv.create(data.dashboard, data.meta);
this.dashboardSrv.setCurrent(dashboard);
// init services
this.timeSrv.init(dashboard);
this.annotationsSrv.init(dashboard);
// template values service needs to initialize completely before
// the rest of the dashboard can load
this.variableSrv
.init(dashboard)
// template values failes are non fatal
.catch(this.onInitFailed.bind(this, 'Templating init failed', false))
// continue
.finally(() => {
this.dashboard = dashboard;
this.dashboard.processRepeats();
this.dashboard.updateSubmenuVisibility();
this.dashboard.autoFitPanels(window.innerHeight);
this.unsavedChangesSrv.init(dashboard, this.$scope);
// TODO refactor ViewStateSrv
this.$scope.dashboard = dashboard;
this.dashboardViewState = this.dashboardViewStateSrv.create(this.$scope);
this.keybindingSrv.setupDashboardBindings(this.$scope, dashboard);
this.setWindowTitleAndTheme();
appEvents.emit('dashboard-initialized', dashboard);
})
.catch(this.onInitFailed.bind(this, 'Dashboard init failed', true));
}
onInitFailed(msg, fatal, err) {
console.log(msg, err);
if (err.data && err.data.message) {
err.message = err.data.message;
} else if (!err.message) {
err = { message: err.toString() };
}
this.$scope.appEvent('alert-error', [msg, err.message]);
// protect against recursive fallbacks
if (fatal && !this.loadedFallbackDashboard) {
this.loadedFallbackDashboard = true;
this.setupDashboard({ dashboard: { title: 'Dashboard Init failed' } });
}
}
templateVariableUpdated() {
this.dashboard.processRepeats();
}
setWindowTitleAndTheme() {
window.document.title = config.windowTitlePrefix + this.dashboard.title;
}
showJsonEditor(evt, options) {
const model = {
object: options.object,
updateHandler: options.updateHandler,
};
this.$scope.appEvent('show-dash-editor', {
src: 'public/app/partials/edit_json.html',
model: model,
});
}
getDashboard() {
return this.dashboard;
}
getPanelContainer() {
return this;
}
onRemovingPanel(evt, options) {
options = options || {};
if (!options.panelId) {
return;
}
const panelInfo = this.dashboard.getPanelInfoById(options.panelId);
removePanel(this.dashboard, panelInfo.panel, true);
}
onDestroy() {
if (this.dashboard) {
this.dashboard.destroy();
}
}
init(dashboard) {
this.$scope.onAppEvent('show-json-editor', this.showJsonEditor.bind(this));
this.$scope.onAppEvent('template-variable-value-updated', this.templateVariableUpdated.bind(this));
this.$scope.onAppEvent('panel-remove', this.onRemovingPanel.bind(this));
this.$scope.$on('$destroy', this.onDestroy.bind(this));
this.setupDashboard(dashboard);
}
}
coreModule.controller('DashboardCtrl', DashboardCtrl);

View File

@@ -0,0 +1,280 @@
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import { DashboardPage, Props, State, mapStateToProps } from './DashboardPage';
import { DashboardModel } from '../state';
import { cleanUpDashboard } from '../state/actions';
import { getNoPayloadActionCreatorMock, NoPayloadActionCreatorMock } from 'app/core/redux';
import { DashboardRouteInfo, DashboardInitPhase } from 'app/types';
jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({}));
interface ScenarioContext {
cleanUpDashboardMock: NoPayloadActionCreatorMock;
dashboard?: DashboardModel;
setDashboardProp: (overrides?: any, metaOverrides?: any) => void;
wrapper?: ShallowWrapper<Props, State, DashboardPage>;
mount: (propOverrides?: Partial<Props>) => void;
setup?: (fn: () => void) => void;
}
function getTestDashboard(overrides?: any, metaOverrides?: any): DashboardModel {
const data = Object.assign(
{
title: 'My dashboard',
panels: [
{
id: 1,
type: 'graph',
title: 'My graph',
gridPos: { x: 0, y: 0, w: 1, h: 1 },
},
],
},
overrides
);
const meta = Object.assign({ canSave: true, canEdit: true }, metaOverrides);
return new DashboardModel(data, meta);
}
function dashboardPageScenario(description, scenarioFn: (ctx: ScenarioContext) => void) {
describe(description, () => {
let setupFn: () => void;
const ctx: ScenarioContext = {
cleanUpDashboardMock: getNoPayloadActionCreatorMock(cleanUpDashboard),
setup: fn => {
setupFn = fn;
},
setDashboardProp: (overrides?: any, metaOverrides?: any) => {
ctx.dashboard = getTestDashboard(overrides, metaOverrides);
ctx.wrapper.setProps({ dashboard: ctx.dashboard });
},
mount: (propOverrides?: Partial<Props>) => {
const props: Props = {
urlSlug: 'my-dash',
$scope: {},
urlUid: '11',
$injector: {},
routeInfo: DashboardRouteInfo.Normal,
urlEdit: false,
urlFullscreen: false,
initPhase: DashboardInitPhase.NotStarted,
isInitSlow: false,
initDashboard: jest.fn(),
updateLocation: jest.fn(),
notifyApp: jest.fn(),
cleanUpDashboard: ctx.cleanUpDashboardMock,
dashboard: null,
};
Object.assign(props, propOverrides);
ctx.dashboard = props.dashboard;
ctx.wrapper = shallow(<DashboardPage {...props} />);
},
};
beforeEach(() => {
setupFn();
});
scenarioFn(ctx);
});
}
describe('DashboardPage', () => {
dashboardPageScenario('Given initial state', ctx => {
ctx.setup(() => {
ctx.mount();
});
it('Should render nothing', () => {
expect(ctx.wrapper).toMatchSnapshot();
});
});
dashboardPageScenario('Dashboard is fetching slowly', ctx => {
ctx.setup(() => {
ctx.mount();
ctx.wrapper.setProps({
isInitSlow: true,
initPhase: DashboardInitPhase.Fetching,
});
});
it('Should render slow init state', () => {
expect(ctx.wrapper).toMatchSnapshot();
});
});
dashboardPageScenario('Dashboard init completed ', ctx => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp();
});
it('Should update title', () => {
expect(document.title).toBe('My dashboard - Grafana');
});
it('Should render dashboard grid', () => {
expect(ctx.wrapper).toMatchSnapshot();
});
});
dashboardPageScenario('When user goes into panel edit', ctx => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp();
ctx.wrapper.setProps({
urlFullscreen: true,
urlEdit: true,
urlPanelId: '1',
});
});
it('Should update model state to fullscreen & edit', () => {
expect(ctx.dashboard.meta.fullscreen).toBe(true);
expect(ctx.dashboard.meta.isEditing).toBe(true);
});
it('Should update component state to fullscreen and edit', () => {
const state = ctx.wrapper.state();
expect(state.isEditing).toBe(true);
expect(state.isFullscreen).toBe(true);
});
});
dashboardPageScenario('When user goes back to dashboard from panel edit', ctx => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp();
ctx.wrapper.setState({ scrollTop: 100 });
ctx.wrapper.setProps({
urlFullscreen: true,
urlEdit: true,
urlPanelId: '1',
});
ctx.wrapper.setProps({
urlFullscreen: false,
urlEdit: false,
urlPanelId: null,
});
});
it('Should update model state normal state', () => {
expect(ctx.dashboard.meta.fullscreen).toBe(false);
expect(ctx.dashboard.meta.isEditing).toBe(false);
});
it('Should update component state to normal and restore scrollTop', () => {
const state = ctx.wrapper.state();
expect(state.isEditing).toBe(false);
expect(state.isFullscreen).toBe(false);
expect(state.scrollTop).toBe(100);
});
});
dashboardPageScenario('When dashboard has editview url state', ctx => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp();
ctx.wrapper.setProps({
editview: 'settings',
});
});
it('should render settings view', () => {
expect(ctx.wrapper).toMatchSnapshot();
});
it('should set animation state', () => {
expect(ctx.wrapper.state().isSettingsOpening).toBe(true);
});
});
dashboardPageScenario('When adding panel', ctx => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp();
ctx.wrapper.setState({ scrollTop: 100 });
ctx.wrapper.instance().onAddPanel();
});
it('should set scrollTop to 0', () => {
expect(ctx.wrapper.state().scrollTop).toBe(0);
});
it('should add panel widget to dashboard panels', () => {
expect(ctx.dashboard.panels[0].type).toBe('add-panel');
});
});
dashboardPageScenario('Given panel with id 0', ctx => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp({
panels: [{ id: 0, type: 'graph' }],
schemaVersion: 17,
});
ctx.wrapper.setProps({
urlEdit: true,
urlFullscreen: true,
urlPanelId: '0',
});
});
it('Should go into edit mode', () => {
expect(ctx.wrapper.state().isEditing).toBe(true);
expect(ctx.wrapper.state().fullscreenPanel.id).toBe(0);
});
});
dashboardPageScenario('When dashboard unmounts', ctx => {
ctx.setup(() => {
ctx.mount();
ctx.setDashboardProp({
panels: [{ id: 0, type: 'graph' }],
schemaVersion: 17,
});
ctx.wrapper.unmount();
});
it('Should call clean up action', () => {
expect(ctx.cleanUpDashboardMock.calls).toBe(1);
});
});
describe('mapStateToProps with bool fullscreen', () => {
const props = mapStateToProps({
location: {
routeParams: {},
query: {
fullscreen: true,
edit: false,
},
},
dashboard: {},
} as any);
expect(props.urlFullscreen).toBe(true);
expect(props.urlEdit).toBe(false);
});
describe('mapStateToProps with string edit true', () => {
const props = mapStateToProps({
location: {
routeParams: {},
query: {
fullscreen: false,
edit: 'true',
},
},
dashboard: {},
} as any);
expect(props.urlFullscreen).toBe(false);
expect(props.urlEdit).toBe(true);
});
});

View File

@@ -0,0 +1,333 @@
// Libraries
import $ from 'jquery';
import React, { PureComponent, MouseEvent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import classNames from 'classnames';
// Services & Utils
import { createErrorNotification } from 'app/core/copy/appNotification';
import { getMessageFromError } from 'app/core/utils/errors';
// Components
import { DashboardGrid } from '../dashgrid/DashboardGrid';
import { DashNav } from '../components/DashNav';
import { SubMenu } from '../components/SubMenu';
import { DashboardSettings } from '../components/DashboardSettings';
import { CustomScrollbar } from '@grafana/ui';
import { AlertBox } from 'app/core/components/AlertBox/AlertBox';
// Redux
import { initDashboard } from '../state/initDashboard';
import { cleanUpDashboard } from '../state/actions';
import { updateLocation } from 'app/core/actions';
import { notifyApp } from 'app/core/actions';
// Types
import {
StoreState,
DashboardInitPhase,
DashboardRouteInfo,
DashboardInitError,
AppNotificationSeverity,
} from 'app/types';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
export interface Props {
urlUid?: string;
urlSlug?: string;
urlType?: string;
editview?: string;
urlPanelId?: string;
urlFolderId?: string;
$scope: any;
$injector: any;
routeInfo: DashboardRouteInfo;
urlEdit: boolean;
urlFullscreen: boolean;
initPhase: DashboardInitPhase;
isInitSlow: boolean;
dashboard: DashboardModel | null;
initError?: DashboardInitError;
initDashboard: typeof initDashboard;
cleanUpDashboard: typeof cleanUpDashboard;
notifyApp: typeof notifyApp;
updateLocation: typeof updateLocation;
}
export interface State {
isSettingsOpening: boolean;
isEditing: boolean;
isFullscreen: boolean;
fullscreenPanel: PanelModel | null;
scrollTop: number;
rememberScrollTop: number;
showLoadingState: boolean;
}
export class DashboardPage extends PureComponent<Props, State> {
state: State = {
isSettingsOpening: false,
isEditing: false,
isFullscreen: false,
showLoadingState: false,
fullscreenPanel: null,
scrollTop: 0,
rememberScrollTop: 0,
};
async componentDidMount() {
this.props.initDashboard({
$injector: this.props.$injector,
$scope: this.props.$scope,
urlSlug: this.props.urlSlug,
urlUid: this.props.urlUid,
urlType: this.props.urlType,
urlFolderId: this.props.urlFolderId,
routeInfo: this.props.routeInfo,
fixUrl: true,
});
}
componentWillUnmount() {
if (this.props.dashboard) {
this.props.cleanUpDashboard();
this.setPanelFullscreenClass(false);
}
}
componentDidUpdate(prevProps: Props) {
const { dashboard, editview, urlEdit, urlFullscreen, urlPanelId, urlUid } = this.props;
if (!dashboard) {
return;
}
// if we just got dashboard update title
if (!prevProps.dashboard) {
document.title = dashboard.title + ' - Grafana';
}
// Due to the angular -> react url bridge we can ge an update here with new uid before the container unmounts
// Can remove this condition after we switch to react router
if (prevProps.urlUid !== urlUid) {
return;
}
// handle animation states when opening dashboard settings
if (!prevProps.editview && editview) {
this.setState({ isSettingsOpening: true });
setTimeout(() => {
this.setState({ isSettingsOpening: false });
}, 10);
}
// Sync url state with model
if (urlFullscreen !== dashboard.meta.fullscreen || urlEdit !== dashboard.meta.isEditing) {
if (!isNaN(parseInt(urlPanelId, 10))) {
this.onEnterFullscreen();
} else {
this.onLeaveFullscreen();
}
}
}
onEnterFullscreen() {
const { dashboard, urlEdit, urlFullscreen, urlPanelId } = this.props;
const panelId = parseInt(urlPanelId, 10);
// need to expand parent row if this panel is inside a row
dashboard.expandParentRowFor(panelId);
const panel = dashboard.getPanelById(panelId);
if (panel) {
dashboard.setViewMode(panel, urlFullscreen, urlEdit);
this.setState({
isEditing: urlEdit && dashboard.meta.canEdit,
isFullscreen: urlFullscreen,
fullscreenPanel: panel,
rememberScrollTop: this.state.scrollTop,
});
this.setPanelFullscreenClass(urlFullscreen);
} else {
this.handleFullscreenPanelNotFound(urlPanelId);
}
}
onLeaveFullscreen() {
const { dashboard } = this.props;
if (this.state.fullscreenPanel) {
dashboard.setViewMode(this.state.fullscreenPanel, false, false);
}
this.setState(
{
isEditing: false,
isFullscreen: false,
fullscreenPanel: null,
scrollTop: this.state.rememberScrollTop,
},
this.triggerPanelsRendering.bind(this)
);
this.setPanelFullscreenClass(false);
}
triggerPanelsRendering() {
try {
this.props.dashboard.render();
} catch (err) {
this.props.notifyApp(createErrorNotification(`Panel rendering error`, err));
}
}
handleFullscreenPanelNotFound(urlPanelId: string) {
// Panel not found
this.props.notifyApp(createErrorNotification(`Panel with id ${urlPanelId} not found`));
// Clear url state
this.props.updateLocation({
query: {
edit: null,
fullscreen: null,
panelId: null,
},
partial: true,
});
}
setPanelFullscreenClass(isFullscreen: boolean) {
$('body').toggleClass('panel-in-fullscreen', isFullscreen);
}
setScrollTop = (e: MouseEvent<HTMLElement>): void => {
const target = e.target as HTMLElement;
this.setState({ scrollTop: target.scrollTop });
};
onAddPanel = () => {
const { dashboard } = this.props;
// Return if the "Add panel" exists already
if (dashboard.panels.length > 0 && dashboard.panels[0].type === 'add-panel') {
return;
}
dashboard.addPanel({
type: 'add-panel',
gridPos: { x: 0, y: 0, w: 12, h: 8 },
title: 'Panel Title',
});
// scroll to top after adding panel
this.setState({ scrollTop: 0 });
};
renderSlowInitState() {
return (
<div className="dashboard-loading">
<div className="dashboard-loading__text">
<i className="fa fa-spinner fa-spin" /> {this.props.initPhase}
</div>
</div>
);
}
renderInitFailedState() {
const { initError } = this.props;
return (
<div className="dashboard-loading">
<AlertBox
severity={AppNotificationSeverity.Error}
title={initError.message}
text={getMessageFromError(initError.error)}
/>
</div>
);
}
render() {
const { dashboard, editview, $injector, isInitSlow, initError } = this.props;
const { isSettingsOpening, isEditing, isFullscreen, scrollTop } = this.state;
if (!dashboard) {
if (isInitSlow) {
return this.renderSlowInitState();
}
return null;
}
const classes = classNames({
'dashboard-page--settings-opening': isSettingsOpening,
'dashboard-page--settings-open': !isSettingsOpening && editview,
});
const gridWrapperClasses = classNames({
'dashboard-container': true,
'dashboard-container--has-submenu': dashboard.meta.submenuEnabled,
});
return (
<div className={classes}>
<DashNav
dashboard={dashboard}
isEditing={isEditing}
isFullscreen={isFullscreen}
editview={editview}
$injector={$injector}
onAddPanel={this.onAddPanel}
/>
<div className="scroll-canvas scroll-canvas--dashboard">
<CustomScrollbar
autoHeightMin={'100%'}
setScrollTop={this.setScrollTop}
scrollTop={scrollTop}
updateAfterMountMs={500}
className="custom-scrollbar--page"
>
{editview && <DashboardSettings dashboard={dashboard} />}
{initError && this.renderInitFailedState()}
<div className={gridWrapperClasses}>
{dashboard.meta.submenuEnabled && <SubMenu dashboard={dashboard} />}
<DashboardGrid dashboard={dashboard} isEditing={isEditing} isFullscreen={isFullscreen} />
</div>
</CustomScrollbar>
</div>
</div>
);
}
}
export const mapStateToProps = (state: StoreState) => ({
urlUid: state.location.routeParams.uid,
urlSlug: state.location.routeParams.slug,
urlType: state.location.routeParams.type,
editview: state.location.query.editview,
urlPanelId: state.location.query.panelId,
urlFolderId: state.location.query.folderId,
urlFullscreen: !!state.location.query.fullscreen,
urlEdit: !!state.location.query.edit,
initPhase: state.dashboard.initPhase,
isInitSlow: state.dashboard.isInitSlow,
initError: state.dashboard.initError,
dashboard: state.dashboard.model as DashboardModel,
});
const mapDispatchToProps = {
initDashboard,
cleanUpDashboard,
notifyApp,
updateLocation,
};
export default hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(DashboardPage)
);

View File

@@ -3,98 +3,84 @@ import React, { Component } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
// Utils & Services
import appEvents from 'app/core/app_events';
import locationUtil from 'app/core/utils/location_util';
import { getBackendSrv } from 'app/core/services/backend_srv';
// Components
import { DashboardPanel } from '../dashgrid/DashboardPanel';
// Redux
import { updateLocation } from 'app/core/actions';
import { initDashboard } from '../state/initDashboard';
// Types
import { StoreState } from 'app/types';
import { StoreState, DashboardRouteInfo } from 'app/types';
import { PanelModel, DashboardModel } from 'app/features/dashboard/state';
interface Props {
panelId: string;
urlPanelId: string;
urlUid?: string;
urlSlug?: string;
urlType?: string;
$scope: any;
$injector: any;
updateLocation: typeof updateLocation;
routeInfo: DashboardRouteInfo;
initDashboard: typeof initDashboard;
dashboard: DashboardModel | null;
}
interface State {
panel: PanelModel | null;
dashboard: DashboardModel | null;
notFound: boolean;
}
export class SoloPanelPage extends Component<Props, State> {
state: State = {
panel: null,
dashboard: null,
notFound: false,
};
componentDidMount() {
const { $injector, $scope, urlUid, urlType, urlSlug } = this.props;
const { $injector, $scope, urlUid, urlType, urlSlug, routeInfo } = this.props;
// handle old urls with no uid
if (!urlUid && !(urlType === 'script' || urlType === 'snapshot')) {
this.redirectToNewUrl();
return;
}
const dashboardLoaderSrv = $injector.get('dashboardLoaderSrv');
// subscribe to event to know when dashboard controller is done with inititalization
appEvents.on('dashboard-initialized', this.onDashoardInitialized);
dashboardLoaderSrv.loadDashboard(urlType, urlSlug, urlUid).then(result => {
result.meta.soloMode = true;
$scope.initDashboard(result, $scope);
this.props.initDashboard({
$injector: $injector,
$scope: $scope,
urlSlug: urlSlug,
urlUid: urlUid,
urlType: urlType,
routeInfo: routeInfo,
fixUrl: false,
});
}
redirectToNewUrl() {
getBackendSrv().getDashboardBySlug(this.props.urlSlug).then(res => {
if (res) {
const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/'));
this.props.updateLocation(url);
componentDidUpdate(prevProps: Props) {
const { urlPanelId, dashboard } = this.props;
if (!dashboard) {
return;
}
// we just got the dashboard!
if (!prevProps.dashboard) {
const panelId = parseInt(urlPanelId, 10);
// need to expand parent row if this panel is inside a row
dashboard.expandParentRowFor(panelId);
const panel = dashboard.getPanelById(panelId);
if (!panel) {
this.setState({ notFound: true });
return;
}
});
}
onDashoardInitialized = () => {
const { $scope, panelId } = this.props;
const dashboard: DashboardModel = $scope.dashboard;
const panel = dashboard.getPanelById(parseInt(panelId, 10));
if (!panel) {
this.setState({ notFound: true });
return;
this.setState({ panel });
}
this.setState({ dashboard, panel });
};
}
render() {
const { panelId } = this.props;
const { notFound, panel, dashboard } = this.state;
const { urlPanelId, dashboard } = this.props;
const { notFound, panel } = this.state;
if (notFound) {
return (
<div className="alert alert-error">
Panel with id { panelId } not found
</div>
);
return <div className="alert alert-error">Panel with id {urlPanelId} not found</div>;
}
if (!panel) {
@@ -113,11 +99,17 @@ const mapStateToProps = (state: StoreState) => ({
urlUid: state.location.routeParams.uid,
urlSlug: state.location.routeParams.slug,
urlType: state.location.routeParams.type,
panelId: state.location.query.panelId
urlPanelId: state.location.query.panelId,
dashboard: state.dashboard.model as DashboardModel,
});
const mapDispatchToProps = {
updateLocation
initDashboard,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(SoloPanelPage));
export default hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(SoloPanelPage)
);

View File

@@ -0,0 +1,548 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DashboardPage Dashboard init completed Should render dashboard grid 1`] = `
<div
className=""
>
<Connect(DashNav)
$injector={Object {}}
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gnetId": null,
"graphTooltip": 0,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"fullscreen": false,
"isEditing": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"datasource": null,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 18,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
isEditing={false}
isFullscreen={false}
onAddPanel={[Function]}
/>
<div
className="scroll-canvas scroll-canvas--dashboard"
>
<CustomScrollbar
autoHeightMax="100%"
autoHeightMin="100%"
autoHide={false}
autoHideDuration={200}
autoHideTimeout={200}
className="custom-scrollbar--page"
hideTracksWhenNotNeeded={false}
scrollTop={0}
setScrollTop={[Function]}
updateAfterMountMs={500}
>
<div
className="dashboard-container"
>
<DashboardGrid
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gnetId": null,
"graphTooltip": 0,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"fullscreen": false,
"isEditing": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"datasource": null,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 18,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
isEditing={false}
isFullscreen={false}
/>
</div>
</CustomScrollbar>
</div>
</div>
`;
exports[`DashboardPage Dashboard is fetching slowly Should render slow init state 1`] = `
<div
className="dashboard-loading"
>
<div
className="dashboard-loading__text"
>
<i
className="fa fa-spinner fa-spin"
/>
Fetching
</div>
</div>
`;
exports[`DashboardPage Given initial state Should render nothing 1`] = `""`;
exports[`DashboardPage When dashboard has editview url state should render settings view 1`] = `
<div
className="dashboard-page--settings-opening"
>
<Connect(DashNav)
$injector={Object {}}
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gnetId": null,
"graphTooltip": 0,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"fullscreen": false,
"isEditing": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"datasource": null,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 18,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
editview="settings"
isEditing={false}
isFullscreen={false}
onAddPanel={[Function]}
/>
<div
className="scroll-canvas scroll-canvas--dashboard"
>
<CustomScrollbar
autoHeightMax="100%"
autoHeightMin="100%"
autoHide={false}
autoHideDuration={200}
autoHideTimeout={200}
className="custom-scrollbar--page"
hideTracksWhenNotNeeded={false}
scrollTop={0}
setScrollTop={[Function]}
updateAfterMountMs={500}
>
<DashboardSettings
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gnetId": null,
"graphTooltip": 0,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"fullscreen": false,
"isEditing": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"datasource": null,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 18,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
/>
<div
className="dashboard-container"
>
<DashboardGrid
dashboard={
DashboardModel {
"annotations": Object {
"list": Array [
Object {
"builtIn": 1,
"datasource": "-- Grafana --",
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard",
},
],
},
"autoUpdate": undefined,
"description": undefined,
"editable": true,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gnetId": null,
"graphTooltip": 0,
"id": null,
"links": Array [],
"meta": Object {
"canEdit": true,
"canMakeEditable": false,
"canSave": true,
"canShare": true,
"canStar": true,
"fullscreen": false,
"isEditing": false,
"showSettings": true,
},
"originalTemplating": Array [],
"originalTime": Object {
"from": "now-6h",
"to": "now",
},
"panels": Array [
PanelModel {
"cachedPluginOptions": Object {},
"datasource": null,
"events": Emitter {
"emitter": EventEmitter {
"_events": Object {},
"_eventsCount": 0,
},
},
"gridPos": Object {
"h": 1,
"w": 1,
"x": 0,
"y": 0,
},
"id": 1,
"targets": Array [
Object {
"refId": "A",
},
],
"title": "My graph",
"transparent": false,
"type": "graph",
},
],
"refresh": undefined,
"revision": undefined,
"schemaVersion": 18,
"snapshot": undefined,
"style": "dark",
"tags": Array [],
"templating": Object {
"list": Array [],
},
"time": Object {
"from": "now-6h",
"to": "now",
},
"timepicker": Object {},
"timezone": "",
"title": "My dashboard",
"uid": null,
"version": 0,
}
}
isEditing={false}
isFullscreen={false}
/>
</div>
</CustomScrollbar>
</div>
</div>
`;

View File

@@ -1,17 +1,20 @@
import React from 'react';
// Libaries
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import ReactGridLayout, { ItemCallback } from 'react-grid-layout';
import classNames from 'classnames';
import sizeMe from 'react-sizeme';
// Types
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
import { DashboardPanel } from './DashboardPanel';
import { DashboardModel, PanelModel } from '../state';
import classNames from 'classnames';
import sizeMe from 'react-sizeme';
let lastGridWidth = 1200;
let ignoreNextWidthChange = false;
interface GridWrapperProps {
size: { width: number; };
size: { width: number };
layout: ReactGridLayout.Layout[];
onLayoutChange: (layout: ReactGridLayout.Layout[]) => void;
children: JSX.Element | JSX.Element[];
@@ -38,7 +41,7 @@ function GridWrapper({
isResizable,
isDraggable,
isFullscreen,
}: GridWrapperProps) {
}: GridWrapperProps) {
const width = size.width > 0 ? size.width : lastGridWidth;
// logic to ignore width changes (optimization)
@@ -76,19 +79,18 @@ function GridWrapper({
const SizedReactLayoutGrid = sizeMe({ monitorWidth: true })(GridWrapper);
export interface DashboardGridProps {
export interface Props {
dashboard: DashboardModel;
isEditing: boolean;
isFullscreen: boolean;
}
export class DashboardGrid extends React.Component<DashboardGridProps> {
export class DashboardGrid extends PureComponent<Props> {
gridToPanelMap: any;
panelMap: { [id: string]: PanelModel };
constructor(props: DashboardGridProps) {
super(props);
// subscribe to dashboard events
const dashboard = this.props.dashboard;
componentDidMount() {
const { dashboard } = this.props;
dashboard.on('panel-added', this.triggerForceUpdate);
dashboard.on('panel-removed', this.triggerForceUpdate);
dashboard.on('repeats-processed', this.triggerForceUpdate);
@@ -97,6 +99,16 @@ export class DashboardGrid extends React.Component<DashboardGridProps> {
dashboard.on('row-expanded', this.triggerForceUpdate);
}
componentWillUnmount() {
const { dashboard } = this.props;
dashboard.off('panel-added', this.triggerForceUpdate);
dashboard.off('panel-removed', this.triggerForceUpdate);
dashboard.off('repeats-processed', this.triggerForceUpdate);
dashboard.off('view-mode-changed', this.onViewModeChanged);
dashboard.off('row-collapsed', this.triggerForceUpdate);
dashboard.off('row-expanded', this.triggerForceUpdate);
}
buildLayout() {
const layout = [];
this.panelMap = {};
@@ -137,22 +149,21 @@ export class DashboardGrid extends React.Component<DashboardGridProps> {
}
this.props.dashboard.sortPanelsByGridPos();
}
};
triggerForceUpdate = () => {
this.forceUpdate();
}
};
onWidthChange = () => {
for (const panel of this.props.dashboard.panels) {
panel.resizeDone();
}
}
};
onViewModeChanged = () => {
ignoreNextWidthChange = true;
this.forceUpdate();
}
};
updateGridPos = (item: ReactGridLayout.Layout, layout: ReactGridLayout.Layout[]) => {
this.panelMap[item.i].updateGridPos(item);
@@ -160,21 +171,21 @@ export class DashboardGrid extends React.Component<DashboardGridProps> {
// react-grid-layout has a bug (#670), and onLayoutChange() is only called when the component is mounted.
// So it's required to call it explicitly when panel resized or moved to save layout changes.
this.onLayoutChange(layout);
}
};
onResize: ItemCallback = (layout, oldItem, newItem) => {
console.log();
this.panelMap[newItem.i].updateGridPos(newItem);
}
};
onResizeStop: ItemCallback = (layout, oldItem, newItem) => {
this.updateGridPos(newItem, layout);
this.panelMap[newItem.i].resizeDone();
}
};
onDragStop: ItemCallback = (layout, oldItem, newItem) => {
this.updateGridPos(newItem, layout);
}
};
renderPanels() {
const panelElements = [];
@@ -197,18 +208,20 @@ export class DashboardGrid extends React.Component<DashboardGridProps> {
}
render() {
const { dashboard, isFullscreen } = this.props;
return (
<SizedReactLayoutGrid
className={classNames({ layout: true })}
layout={this.buildLayout()}
isResizable={this.props.dashboard.meta.canEdit}
isDraggable={this.props.dashboard.meta.canEdit}
isResizable={dashboard.meta.canEdit}
isDraggable={dashboard.meta.canEdit}
onLayoutChange={this.onLayoutChange}
onWidthChange={this.onWidthChange}
onDragStop={this.onDragStop}
onResize={this.onResize}
onResizeStop={this.onResizeStop}
isFullscreen={this.props.dashboard.meta.fullscreen}
isFullscreen={isFullscreen}
>
{this.renderPanels()}
</SizedReactLayoutGrid>

View File

@@ -68,7 +68,7 @@ export class DashboardPanel extends PureComponent<Props, State> {
// handle plugin loading & changing of plugin type
if (!this.state.plugin || this.state.plugin.id !== pluginId) {
const plugin = config.panels[pluginId] || getPanelPluginNotFound(pluginId);
let plugin = config.panels[pluginId] || getPanelPluginNotFound(pluginId);
// remember if this is from an angular panel
const fromAngularPanel = this.state.angularPanel != null;
@@ -76,16 +76,26 @@ export class DashboardPanel extends PureComponent<Props, State> {
// unmount angular panel
this.cleanUpAngularPanel();
if (panel.type !== pluginId) {
this.props.panel.changeType(pluginId, fromAngularPanel);
if (!plugin.exports) {
try {
plugin.exports = await importPluginModule(plugin.module);
} catch (e) {
plugin = getPanelPluginNotFound(pluginId);
}
}
if (plugin.exports) {
this.setState({ plugin: plugin, angularPanel: null });
} else {
plugin.exports = await importPluginModule(plugin.module);
this.setState({ plugin: plugin, angularPanel: null });
if (panel.type !== pluginId) {
if (fromAngularPanel) {
// for angular panels only we need to remove all events and let angular panels do some cleanup
panel.destroy();
this.props.panel.changeType(pluginId);
} else {
panel.changeType(pluginId, plugin.exports.reactPanel.preserveOptions);
}
}
this.setState({ plugin, angularPanel: null });
}
}
@@ -126,10 +136,10 @@ export class DashboardPanel extends PureComponent<Props, State> {
};
renderReactPanel() {
const { dashboard, panel } = this.props;
const { dashboard, panel, isFullscreen } = this.props;
const { plugin } = this.state;
return <PanelChrome plugin={plugin} panel={panel} dashboard={dashboard} />;
return <PanelChrome plugin={plugin} panel={panel} dashboard={dashboard} isFullscreen={isFullscreen} />;
}
renderAngularPanel() {
@@ -168,7 +178,7 @@ export class DashboardPanel extends PureComponent<Props, State> {
onMouseLeave={this.onMouseLeave}
style={styles}
>
{plugin.exports.Panel && this.renderReactPanel()}
{plugin.exports.reactPanel && this.renderReactPanel()}
{plugin.exports.PanelCtrl && this.renderAngularPanel()}
</div>
)}

View File

@@ -1,8 +1,6 @@
// Library
import React, { Component } from 'react';
import { Tooltip } from '@grafana/ui';
import ErrorBoundary from 'app/core/components/ErrorBoundary/ErrorBoundary';
// Services
import { DatasourceSrv, getDatasourceSrv } from 'app/features/plugins/datasource_srv';
// Utils
@@ -11,15 +9,15 @@ import kbn from 'app/core/utils/kbn';
import {
DataQueryOptions,
DataQueryResponse,
DataQueryError,
LoadingState,
PanelData,
TableData,
TimeRange,
TimeSeries,
ScopedVars,
} from '@grafana/ui';
const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
interface RenderProps {
loading: LoadingState;
panelData: PanelData;
@@ -28,7 +26,7 @@ interface RenderProps {
export interface Props {
datasource: string | null;
queries: any[];
panelId?: number;
panelId: number;
dashboardId?: number;
isVisible?: boolean;
timeRange?: TimeRange;
@@ -36,21 +34,22 @@ export interface Props {
refreshCounter: number;
minInterval?: string;
maxDataPoints?: number;
scopedVars?: ScopedVars;
children: (r: RenderProps) => JSX.Element;
onDataResponse?: (data: DataQueryResponse) => void;
onError: (message: string, error: DataQueryError) => void;
}
export interface State {
isFirstLoad: boolean;
loading: LoadingState;
errorMessage: string;
response: DataQueryResponse;
panelData: PanelData;
}
export class DataPanel extends Component<Props, State> {
static defaultProps = {
isVisible: true,
panelId: 1,
dashboardId: 1,
};
@@ -62,10 +61,10 @@ export class DataPanel extends Component<Props, State> {
this.state = {
loading: LoadingState.NotStarted,
errorMessage: '',
response: {
data: [],
},
panelData: {},
isFirstLoad: true,
};
}
@@ -100,7 +99,9 @@ export class DataPanel extends Component<Props, State> {
timeRange,
widthPixels,
maxDataPoints,
scopedVars,
onDataResponse,
onError,
} = this.props;
if (!isVisible) {
@@ -112,10 +113,10 @@ export class DataPanel extends Component<Props, State> {
return;
}
this.setState({ loading: LoadingState.Loading, errorMessage: '' });
this.setState({ loading: LoadingState.Loading });
try {
const ds = await this.dataSourceSrv.get(datasource);
const ds = await this.dataSourceSrv.get(datasource, scopedVars);
// TODO interpolate variables
const minInterval = this.props.minInterval || ds.interval;
@@ -131,7 +132,7 @@ export class DataPanel extends Component<Props, State> {
intervalMs: intervalRes.intervalMs,
targets: queries,
maxDataPoints: maxDataPoints || widthPixels,
scopedVars: {},
scopedVars: scopedVars || {},
cacheTimeout: null,
};
@@ -148,27 +149,30 @@ export class DataPanel extends Component<Props, State> {
this.setState({
loading: LoadingState.Done,
response: resp,
panelData: this.getPanelData(resp),
isFirstLoad: false,
});
} catch (err) {
console.log('Loading error', err);
this.onError('Request Error');
console.log('DataPanel error', err);
let message = 'Query error';
if (err.message) {
message = err.message;
} else if (err.data && err.data.message) {
message = err.data.message;
} else if (err.data && err.data.error) {
message = err.data.error;
} else if (err.status) {
message = `Query error: ${err.status} ${err.statusText}`;
}
onError(message, err);
this.setState({ isFirstLoad: false, loading: LoadingState.Error });
}
};
onError = (errorMessage: string) => {
if (this.state.loading !== LoadingState.Error || this.state.errorMessage !== errorMessage) {
this.setState({
loading: LoadingState.Error,
isFirstLoad: false,
errorMessage: errorMessage,
});
}
};
getPanelData = () => {
const { response } = this.state;
getPanelData(response: DataQueryResponse) {
if (response.data.length > 0 && (response.data[0] as TableData).type === 'table') {
return {
tableData: response.data[0] as TableData,
@@ -180,16 +184,15 @@ export class DataPanel extends Component<Props, State> {
timeSeries: response.data as TimeSeries[],
tableData: null,
};
};
}
render() {
const { queries } = this.props;
const { loading, isFirstLoad } = this.state;
const { loading, isFirstLoad, panelData } = this.state;
const panelData = this.getPanelData();
if (isFirstLoad && loading === LoadingState.Loading) {
return this.renderLoadingStates();
// do not render component until we have first data
if (isFirstLoad && (loading === LoadingState.Loading || loading === LoadingState.NotStarted)) {
return this.renderLoadingState();
}
if (!queries.length) {
@@ -202,46 +205,17 @@ export class DataPanel extends Component<Props, State> {
return (
<>
{this.renderLoadingStates()}
<ErrorBoundary>
{({ error, errorInfo }) => {
if (errorInfo) {
this.onError(error.message || DEFAULT_PLUGIN_ERROR);
return null;
}
return (
<>
{this.props.children({
loading,
panelData,
})}
</>
);
}}
</ErrorBoundary>
{loading === LoadingState.Loading && this.renderLoadingState()}
{this.props.children({ loading, panelData })}
</>
);
}
private renderLoadingStates(): JSX.Element {
const { loading, errorMessage } = this.state;
if (loading === LoadingState.Loading) {
return (
<div className="panel-loading">
<i className="fa fa-spinner fa-spin" />
</div>
);
} else if (loading === LoadingState.Error) {
return (
<Tooltip content={errorMessage} placement="bottom-start" theme="error">
<div className="panel-info-corner panel-info-corner--error">
<i className="fa" />
<span className="panel-info-corner-inner" />
</div>
</Tooltip>
);
}
return null;
private renderLoadingState(): JSX.Element {
return (
<div className="panel-loading">
<i className="fa fa-spinner fa-spin" />
</div>
);
}
}

View File

@@ -0,0 +1,30 @@
import { PanelChrome } from './PanelChrome';
describe('PanelChrome', () => {
let chrome: PanelChrome;
beforeEach(() => {
chrome = new PanelChrome({
panel: {
scopedVars: {
aaa: { value: 'AAA', text: 'upperA' },
bbb: { value: 'BBB', text: 'upperB' },
},
},
dashboard: {},
plugin: {},
isFullscreen: false,
});
});
it('Should replace a panel variable', () => {
const out = chrome.replaceVariables('hello $aaa');
expect(out).toBe('hello AAA');
});
it('But it should prefer the local variable value', () => {
const extra = { aaa: { text: '???', value: 'XXX' } };
const out = chrome.replaceVariables('hello $aaa and $bbb', extra);
expect(out).toBe('hello XXX and BBB');
});
});

View File

@@ -8,25 +8,29 @@ import { getTimeSrv, TimeSrv } from '../services/TimeSrv';
// Components
import { PanelHeader } from './PanelHeader/PanelHeader';
import { DataPanel } from './DataPanel';
import ErrorBoundary from '../../../core/components/ErrorBoundary/ErrorBoundary';
// Utils
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
import { applyPanelTimeOverrides, snapshotDataToPanelData } from 'app/features/dashboard/utils/panel';
import { PANEL_HEADER_HEIGHT } from 'app/core/constants';
import { profiler } from 'app/core/profiler';
import config from 'app/core/config';
// Types
import { DashboardModel, PanelModel } from '../state';
import { PanelPlugin } from 'app/types';
import { TimeRange, LoadingState } from '@grafana/ui';
import { DataQueryResponse, TimeRange, LoadingState, PanelData, DataQueryError } from '@grafana/ui';
import { ScopedVars } from '@grafana/ui';
import variables from 'sass/_variables.scss';
import templateSrv from 'app/features/templating/template_srv';
import { DataQueryResponse } from '@grafana/ui/src';
const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
export interface Props {
panel: PanelModel;
dashboard: DashboardModel;
plugin: PanelPlugin;
isFullscreen: boolean;
}
export interface State {
@@ -34,6 +38,7 @@ export interface State {
renderCounter: number;
timeInfo?: string;
timeRange?: TimeRange;
errorMessage: string | null;
}
export class PanelChrome extends PureComponent<Props, State> {
@@ -45,6 +50,7 @@ export class PanelChrome extends PureComponent<Props, State> {
this.state = {
refreshCounter: 0,
renderCounter: 0,
errorMessage: null,
};
}
@@ -80,24 +86,66 @@ export class PanelChrome extends PureComponent<Props, State> {
});
};
onInterpolate = (value: string, format?: string) => {
return templateSrv.replace(value, this.props.panel.scopedVars, format);
replaceVariables = (value: string, extraVars?: ScopedVars, format?: string) => {
let vars = this.props.panel.scopedVars;
if (extraVars) {
vars = vars ? { ...vars, ...extraVars } : extraVars;
}
return templateSrv.replace(value, vars, format);
};
onDataResponse = (dataQueryResponse: DataQueryResponse) => {
if (this.props.dashboard.isSnapshot()) {
this.props.panel.snapshotData = dataQueryResponse.data;
}
// clear error state (if any)
this.clearErrorState();
// This event is used by old query editors and panel editor options
this.props.panel.events.emit('data-received', dataQueryResponse.data);
};
onDataError = (message: string, error: DataQueryError) => {
if (this.state.errorMessage !== message) {
this.setState({ errorMessage: message });
}
// this event is used by old query editors
this.props.panel.events.emit('data-error', error);
};
onPanelError = (message: string) => {
if (this.state.errorMessage !== message) {
this.setState({ errorMessage: message });
}
};
clearErrorState() {
if (this.state.errorMessage) {
this.setState({ errorMessage: null });
}
}
get isVisible() {
return !this.props.dashboard.otherPanelInFullscreen(this.props.panel);
}
renderPanel(loading, panelData, width, height): JSX.Element {
get hasPanelSnapshot() {
const { panel } = this.props;
return panel.snapshotData && panel.snapshotData.length;
}
get needsQueryExecution() {
return this.hasPanelSnapshot || this.props.plugin.dataFormats.length > 0;
}
get getDataForPanel() {
return this.hasPanelSnapshot ? snapshotDataToPanelData(this.props.panel) : null;
}
renderPanelPlugin(loading: LoadingState, panelData: PanelData, width: number, height: number): JSX.Element {
const { panel, plugin } = this.props;
const { timeRange, renderCounter } = this.state;
const PanelComponent = plugin.exports.Panel;
const PanelComponent = plugin.exports.reactPanel.panel;
// This is only done to increase a counter that is used by backend
// image rendering (phantomjs/headless chrome) to know when to capture image
@@ -111,21 +159,51 @@ export class PanelChrome extends PureComponent<Props, State> {
loading={loading}
panelData={panelData}
timeRange={timeRange}
options={panel.getOptions(plugin.exports.PanelDefaults)}
width={width - 2 * variables.panelHorizontalPadding}
height={height - PANEL_HEADER_HEIGHT - variables.panelVerticalPadding}
options={panel.getOptions(plugin.exports.reactPanel.defaults)}
width={width - 2 * config.theme.panelPadding.horizontal}
height={height - PANEL_HEADER_HEIGHT - config.theme.panelPadding.vertical}
renderCounter={renderCounter}
onInterpolate={this.onInterpolate}
replaceVariables={this.replaceVariables}
/>
</div>
);
}
render() {
const { panel, dashboard } = this.props;
const { refreshCounter, timeRange, timeInfo } = this.state;
renderPanelBody = (width: number, height: number): JSX.Element => {
const { panel } = this.props;
const { refreshCounter, timeRange } = this.state;
const { datasource, targets } = panel;
return (
<>
{this.needsQueryExecution ? (
<DataPanel
panelId={panel.id}
datasource={datasource}
queries={targets}
timeRange={timeRange}
isVisible={this.isVisible}
widthPixels={width}
refreshCounter={refreshCounter}
scopedVars={panel.scopedVars}
onDataResponse={this.onDataResponse}
onError={this.onDataError}
>
{({ loading, panelData }) => {
return this.renderPanelPlugin(loading, panelData, width, height);
}}
</DataPanel>
) : (
this.renderPanelPlugin(LoadingState.Done, this.getDataForPanel, width, height)
)}
</>
);
};
render() {
const { dashboard, panel, isFullscreen } = this.props;
const { errorMessage, timeInfo } = this.state;
const { transparent } = panel;
const { datasource, targets, transparent } = panel;
const containerClassNames = `panel-container panel-container--absolute ${transparent ? 'panel-transparent' : ''}`;
return (
<AutoSizer>
@@ -144,24 +222,18 @@ export class PanelChrome extends PureComponent<Props, State> {
description={panel.description}
scopedVars={panel.scopedVars}
links={panel.links}
error={errorMessage}
isFullscreen={isFullscreen}
/>
{panel.snapshotData ? (
this.renderPanel(false, panel.snapshotData, width, height)
) : (
<DataPanel
datasource={datasource}
queries={targets}
timeRange={timeRange}
isVisible={this.isVisible}
widthPixels={width}
refreshCounter={refreshCounter}
onDataResponse={this.onDataResponse}
>
{({ loading, panelData }) => {
return this.renderPanel(loading, panelData, width, height);
}}
</DataPanel>
)}
<ErrorBoundary>
{({ error, errorInfo }) => {
if (errorInfo) {
this.onPanelError(error.message || DEFAULT_PLUGIN_ERROR);
return null;
}
return this.renderPanelBody(width, height);
}}
</ErrorBoundary>
</div>
);
}}

View File

@@ -1,6 +1,7 @@
import React, { Component } from 'react';
import classNames from 'classnames';
import { isEqual } from 'lodash';
import { ScopedVars } from '@grafana/ui';
import PanelHeaderCorner from './PanelHeaderCorner';
import { PanelHeaderMenu } from './PanelHeaderMenu';
@@ -16,8 +17,10 @@ export interface Props {
timeInfo: string;
title?: string;
description?: string;
scopedVars?: string;
scopedVars?: ScopedVars;
links?: [];
error?: string;
isFullscreen: boolean;
}
interface ClickCoordinates {
@@ -30,18 +33,18 @@ interface State {
}
export class PanelHeader extends Component<Props, State> {
clickCoordinates: ClickCoordinates = {x: 0, y: 0};
clickCoordinates: ClickCoordinates = { x: 0, y: 0 };
state = {
panelMenuOpen: false,
clickCoordinates: {x: 0, y: 0}
clickCoordinates: { x: 0, y: 0 },
};
eventToClickCoordinates = (event: React.MouseEvent<HTMLDivElement>) => {
return {
x: event.clientX,
y: event.clientY
y: event.clientY,
};
}
};
onMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
this.clickCoordinates = this.eventToClickCoordinates(event);
@@ -49,7 +52,7 @@ export class PanelHeader extends Component<Props, State> {
isClick = (clickCoordinates: ClickCoordinates) => {
return isEqual(clickCoordinates, this.clickCoordinates);
}
};
onMenuToggle = (event: React.MouseEvent<HTMLDivElement>) => {
if (this.isClick(this.eventToClickCoordinates(event))) {
@@ -68,10 +71,9 @@ export class PanelHeader extends Component<Props, State> {
};
render() {
const isFullscreen = false;
const isLoading = false;
const { panel, dashboard, timeInfo, scopedVars, error, isFullscreen } = this.props;
const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen });
const { panel, dashboard, timeInfo, scopedVars } = this.props;
const title = templateSrv.replaceWithText(panel.title, scopedVars);
return (
@@ -82,13 +84,9 @@ export class PanelHeader extends Component<Props, State> {
description={panel.description}
scopedVars={panel.scopedVars}
links={panel.links}
error={error}
/>
<div className={panelHeaderClass}>
{isLoading && (
<span className="panel-loading">
<i className="fa fa-spinner fa-spin" />
</span>
)}
<div className="panel-title-container" onClick={this.onMenuToggle} onMouseDown={this.onMouseDown}>
<div className="panel-title">
<span className="icon-gf panel-alert-icon" />

View File

@@ -1,12 +1,13 @@
import React, { Component } from 'react';
import Remarkable from 'remarkable';
import { Tooltip } from '@grafana/ui';
import { Tooltip, ScopedVars } from '@grafana/ui';
import { PanelModel } from 'app/features/dashboard/state/PanelModel';
import templateSrv from 'app/features/templating/template_srv';
import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
import { getTimeSrv, TimeSrv } from 'app/features/dashboard/services/TimeSrv';
enum InfoModes {
enum InfoMode {
Error = 'Error',
Info = 'Info',
Links = 'Links',
@@ -16,20 +17,24 @@ interface Props {
panel: PanelModel;
title?: string;
description?: string;
scopedVars?: string;
scopedVars?: ScopedVars;
links?: [];
error?: string;
}
export class PanelHeaderCorner extends Component<Props> {
timeSrv: TimeSrv = getTimeSrv();
getInfoMode = () => {
const { panel } = this.props;
const { panel, error } = this.props;
if (error) {
return InfoMode.Error;
}
if (!!panel.description) {
return InfoModes.Info;
return InfoMode.Info;
}
if (panel.links && panel.links.length) {
return InfoModes.Links;
return InfoMode.Links;
}
return undefined;
@@ -42,54 +47,55 @@ export class PanelHeaderCorner extends Component<Props> {
const interpolatedMarkdown = templateSrv.replace(markdown, panel.scopedVars);
const remarkableInterpolatedMarkdown = new Remarkable().render(interpolatedMarkdown);
const html = (
return (
<div className="markdown-html">
<div dangerouslySetInnerHTML={{ __html: remarkableInterpolatedMarkdown }} />
{panel.links &&
panel.links.length > 0 && (
<ul className="text-left">
{panel.links.map((link, idx) => {
const info = linkSrv.getPanelLinkAnchorInfo(link, panel.scopedVars);
return (
<li key={idx}>
<a className="panel-menu-link" href={info.href} target={info.target}>
{info.title}
</a>
</li>
);
})}
</ul>
)}
{panel.links && panel.links.length > 0 && (
<ul className="text-left">
{panel.links.map((link, idx) => {
const info = linkSrv.getPanelLinkAnchorInfo(link, panel.scopedVars);
return (
<li key={idx}>
<a className="panel-menu-link" href={info.href} target={info.target}>
{info.title}
</a>
</li>
);
})}
</ul>
)}
</div>
);
return html;
};
renderCornerType(infoMode: InfoMode, content: string | JSX.Element) {
const theme = infoMode === InfoMode.Error ? 'error' : 'info';
return (
<Tooltip content={content} placement="bottom-start" theme={theme}>
<div className={`panel-info-corner panel-info-corner--${infoMode.toLowerCase()}`}>
<i className="fa" />
<span className="panel-info-corner-inner" />
</div>
</Tooltip>
);
}
render() {
const infoMode: InfoModes | undefined = this.getInfoMode();
const infoMode: InfoMode | undefined = this.getInfoMode();
if (!infoMode) {
return null;
}
return (
<>
{infoMode === InfoModes.Info || infoMode === InfoModes.Links ? (
<Tooltip
content={this.getInfoContent()}
placement="bottom-start"
>
<div
className={`panel-info-corner panel-info-corner--${infoMode.toLowerCase()}`}
>
<i className="fa" />
<span className="panel-info-corner-inner" />
</div>
</Tooltip>
) : null}
</>
);
if (infoMode === InfoMode.Error) {
return this.renderCornerType(infoMode, this.props.error);
}
if (infoMode === InfoMode.Info) {
return this.renderCornerType(infoMode, this.getInfoContent());
}
return null;
}
}

View File

@@ -2,9 +2,12 @@
import _ from 'lodash';
import React, { PureComponent } from 'react';
// Components
import { AlertBox } from 'app/core/components/AlertBox/AlertBox';
// Types
import { PanelProps } from '@grafana/ui';
import { PanelPlugin } from 'app/types';
import { PanelPlugin, AppNotificationSeverity } from 'app/types';
import { PanelProps, ReactPanelPlugin } from '@grafana/ui';
interface Props {
pluginId: string;
@@ -19,15 +22,13 @@ class PanelPluginNotFound extends PureComponent<Props> {
const style = {
display: 'flex',
alignItems: 'center',
textAlign: 'center' as 'center',
justifyContent: 'center',
height: '100%',
};
return (
<div style={style}>
<div className="alert alert-error" style={{ margin: '0 auto' }}>
Panel plugin with id {this.props.pluginId} could not be found
</div>
<AlertBox severity={AppNotificationSeverity.Error} title={`Panel plugin not found: ${this.props.pluginId}`} />
</div>
);
}
@@ -46,6 +47,7 @@ export function getPanelPluginNotFound(id: string): PanelPlugin {
sort: 100,
module: '',
baseUrl: '',
dataFormats: [],
info: {
author: {
name: '',
@@ -62,7 +64,7 @@ export function getPanelPluginNotFound(id: string): PanelPlugin {
},
exports: {
Panel: NotFound,
reactPanel: new ReactPanelPlugin(NotFound),
},
};
}

View File

@@ -66,7 +66,7 @@ export class PanelResizer extends PureComponent<Props, State> {
return (
<>
{render(isEditing ? {height: editorHeight} : this.noStyles)}
{render(isEditing ? { height: editorHeight } : this.noStyles)}
{isEditing && (
<div className="panel-editor-container__resizer">
<Draggable axis="y" grid={[100, 1]} onDrag={this.onDrag} position={{ x: 0, y: 0 }}>

View File

@@ -1,8 +1,6 @@
import './containers/DashboardCtrl';
import './dashgrid/DashboardGridDirective';
// Services
import './services/DashboardViewStateSrv';
import './services/UnsavedChangesSrv';
import './services/DashboardLoaderSrv';
import './services/DashboardSrv';
@@ -29,4 +27,3 @@ import DashboardPermissions from './components/DashboardPermissions/DashboardPer
import { react2AngularDirective } from 'app/core/utils/react2angular';
react2AngularDirective('dashboardPermissions', DashboardPermissions, ['dashboardId', 'folder']);

View File

@@ -1,16 +1,17 @@
import React, { FC } from 'react';
import React, { FC, ChangeEvent } from 'react';
import { FormLabel } from '@grafana/ui';
interface Props {
label: string;
placeholder?: string;
name?: string;
value?: string;
onChange?: (evt: any) => void;
name: string;
value: string;
onBlur: (event: ChangeEvent<HTMLInputElement>) => void;
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
tooltipInfo?: any;
}
export const DataSourceOptions: FC<Props> = ({ label, placeholder, name, value, onChange, tooltipInfo }) => {
export const DataSourceOption: FC<Props> = ({ label, placeholder, name, value, onBlur, onChange, tooltipInfo }) => {
return (
<div className="gf-form gf-form--flex-end">
<FormLabel tooltip={tooltipInfo}>{label}</FormLabel>
@@ -20,10 +21,10 @@ export const DataSourceOptions: FC<Props> = ({ label, placeholder, name, value,
placeholder={placeholder}
name={name}
spellCheck={false}
onBlur={evt => onChange(evt.target.value)}
onBlur={onBlur}
onChange={onChange}
value={value}
/>
</div>
);
};
export default DataSourceOptions;

View File

@@ -118,7 +118,7 @@ export class EditorTabBody extends PureComponent<Props, State> {
{toolbarItems.map(item => this.renderButton(item))}
</div>
<div className="panel-editor__scroll">
<CustomScrollbar autoHide={false} scrollTop={scrollTop} setScrollTop={setScrollTop}>
<CustomScrollbar autoHide={false} scrollTop={scrollTop} setScrollTop={setScrollTop} updateAfterMountMs={300}>
<div className="panel-editor__content">
<FadeIn in={isOpen} duration={200} unmountOnExit={true}>
{openView && this.renderOpenView(openView)}

View File

@@ -44,7 +44,7 @@ export class GeneralTab extends PureComponent<Props> {
render() {
return (
<EditorTabBody heading="Panel Options" toolbarItems={[]}>
<EditorTabBody heading="General" toolbarItems={[]}>
<div ref={element => (this.element = element)} />
</EditorTabBody>
);

View File

@@ -2,7 +2,7 @@ import React, { PureComponent } from 'react';
import classNames from 'classnames';
import { QueriesTab } from './QueriesTab';
import { VisualizationTab } from './VisualizationTab';
import VisualizationTab from './VisualizationTab';
import { GeneralTab } from './GeneralTab';
import { AlertTab } from '../../alerting/AlertTab';
@@ -30,6 +30,32 @@ interface PanelEditorTab {
text: string;
}
enum PanelEditorTabIds {
Queries = 'queries',
Visualization = 'visualization',
Advanced = 'advanced',
Alert = 'alert',
}
interface PanelEditorTab {
id: string;
text: string;
}
const panelEditorTabTexts = {
[PanelEditorTabIds.Queries]: 'Queries',
[PanelEditorTabIds.Visualization]: 'Visualization',
[PanelEditorTabIds.Advanced]: 'General',
[PanelEditorTabIds.Alert]: 'Alert',
};
const getPanelEditorTab = (tabId: PanelEditorTabIds): PanelEditorTab => {
return {
id: tabId,
text: panelEditorTabTexts[tabId],
};
};
export class PanelEditor extends PureComponent<PanelEditorProps> {
constructor(props) {
super(props);
@@ -38,7 +64,7 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
onChangeTab = (tab: PanelEditorTab) => {
store.dispatch(
updateLocation({
query: { tab: tab.id },
query: { tab: tab.id, openVizPicker: null },
partial: true,
})
);
@@ -72,31 +98,26 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
render() {
const { plugin } = this.props;
let activeTab = store.getState().location.query.tab || 'queries';
let activeTab: PanelEditorTabIds = store.getState().location.query.tab || PanelEditorTabIds.Queries;
const tabs: PanelEditorTab[] = [
{ id: 'queries', text: 'Queries' },
{ id: 'visualization', text: 'Visualization' },
{ id: 'advanced', text: 'Panel Options' },
getPanelEditorTab(PanelEditorTabIds.Queries),
getPanelEditorTab(PanelEditorTabIds.Visualization),
getPanelEditorTab(PanelEditorTabIds.Advanced),
];
// handle panels that do not have queries tab
if (plugin.exports.PanelCtrl) {
if (!plugin.exports.PanelCtrl.prototype.onDataReceived) {
// remove queries tab
tabs.shift();
// switch tab
if (activeTab === 'queries') {
activeTab = 'visualization';
}
if (plugin.dataFormats.length === 0) {
// remove queries tab
tabs.shift();
// switch tab
if (activeTab === PanelEditorTabIds.Queries) {
activeTab = PanelEditorTabIds.Visualization;
}
}
if (config.alertingEnabled && plugin.id === 'graph') {
tabs.push({
id: 'alert',
text: 'Alert',
});
tabs.push(getPanelEditorTab(PanelEditorTabIds.Alert));
}
return (

View File

@@ -133,9 +133,9 @@ export class QueriesTab extends PureComponent<Props, State> {
return (
<>
<DataSourcePicker datasources={this.datasources} onChange={this.onChangeDataSource} current={currentDS} />
<div className="flex-grow" />
<div className="flex-grow-1" />
{!isAddingMixed && (
<button className="btn navbar-button navbar-button--primary" onClick={this.onAddQueryClick}>
<button className="btn navbar-button" onClick={this.onAddQueryClick}>
Add Query
</button>
)}
@@ -176,7 +176,7 @@ export class QueriesTab extends PureComponent<Props, State> {
};
render() {
const { panel } = this.props;
const { panel, dashboard } = this.props;
const { currentDS, scrollTop } = this.state;
const queryInspector: EditorToolbarView = {
@@ -205,6 +205,7 @@ export class QueriesTab extends PureComponent<Props, State> {
dataSourceValue={query.datasource || panel.datasource}
key={query.refId}
panel={panel}
dashboard={dashboard}
query={query}
onChange={query => this.onQueryChange(query, index)}
onRemoveQuery={this.onRemoveQuery}

View File

@@ -7,14 +7,17 @@ import _ from 'lodash';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
import { Emitter } from 'app/core/utils/emitter';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
// Types
import { PanelModel } from '../state/PanelModel';
import { DataQuery, DataSourceApi } from '@grafana/ui';
import { DataQuery, DataSourceApi, TimeRange } from '@grafana/ui';
import { DashboardModel } from '../state/DashboardModel';
interface Props {
panel: PanelModel;
query: DataQuery;
dashboard: DashboardModel;
onAddQuery: (query?: DataQuery) => void;
onRemoveQuery: (query: DataQuery) => void;
onMoveQuery: (query: DataQuery, direction: number) => void;
@@ -27,35 +30,73 @@ interface State {
loadedDataSourceValue: string | null | undefined;
datasource: DataSourceApi | null;
isCollapsed: boolean;
angularScope: AngularQueryComponentScope | null;
hasTextEditMode: boolean;
}
export class QueryEditorRow extends PureComponent<Props, State> {
element: HTMLElement | null = null;
angularScope: AngularQueryComponentScope | null;
angularQueryEditor: AngularComponent | null = null;
state: State = {
datasource: null,
isCollapsed: false,
angularScope: null,
loadedDataSourceValue: undefined,
hasTextEditMode: false,
};
componentDidMount() {
this.loadDatasource();
this.props.panel.events.on('refresh', this.onPanelRefresh);
this.props.panel.events.on('data-error', this.onPanelDataError);
this.props.panel.events.on('data-received', this.onPanelDataReceived);
}
componentWillUnmount() {
this.props.panel.events.off('refresh', this.onPanelRefresh);
this.props.panel.events.off('data-error', this.onPanelDataError);
this.props.panel.events.off('data-received', this.onPanelDataReceived);
if (this.angularQueryEditor) {
this.angularQueryEditor.destroy();
}
}
onPanelDataError = () => {
// Some query controllers listen to data error events and need a digest
if (this.angularQueryEditor) {
// for some reason this needs to be done in next tick
setTimeout(this.angularQueryEditor.digest);
}
};
onPanelDataReceived = () => {
// Some query controllers listen to data error events and need a digest
if (this.angularQueryEditor) {
// for some reason this needs to be done in next tick
setTimeout(this.angularQueryEditor.digest);
}
};
onPanelRefresh = () => {
if (this.angularScope) {
this.angularScope.range = getTimeSrv().timeRange();
}
};
getAngularQueryComponentScope(): AngularQueryComponentScope {
const { panel, query } = this.props;
const { panel, query, dashboard } = this.props;
const { datasource } = this.state;
return {
datasource: datasource,
target: query,
panel: panel,
dashboard: dashboard,
refresh: () => panel.refresh(),
render: () => panel.render(),
events: panel.events,
range: getTimeSrv().timeRange(),
};
}
@@ -64,7 +105,11 @@ export class QueryEditorRow extends PureComponent<Props, State> {
const dataSourceSrv = getDatasourceSrv();
const datasource = await dataSourceSrv.get(query.datasource || panel.datasource);
this.setState({ datasource, loadedDataSourceValue: this.props.dataSourceValue });
this.setState({
datasource,
loadedDataSourceValue: this.props.dataSourceValue,
hasTextEditMode: false,
});
}
componentDidUpdate() {
@@ -89,19 +134,14 @@ export class QueryEditorRow extends PureComponent<Props, State> {
const scopeProps = { ctrl: this.getAngularQueryComponentScope() };
this.angularQueryEditor = loader.load(this.element, scopeProps, template);
this.angularScope = scopeProps.ctrl;
// give angular time to compile
setTimeout(() => {
this.setState({ angularScope: scopeProps.ctrl });
this.setState({ hasTextEditMode: !!this.angularScope.toggleEditorMode });
}, 10);
}
componentWillUnmount() {
if (this.angularQueryEditor) {
this.angularQueryEditor.destroy();
}
}
onToggleCollapse = () => {
this.setState({ isCollapsed: !this.state.isCollapsed });
};
@@ -120,24 +160,15 @@ export class QueryEditorRow extends PureComponent<Props, State> {
if (datasource.pluginExports.QueryEditor) {
const QueryEditor = datasource.pluginExports.QueryEditor;
return (
<QueryEditor
query={query}
datasource={datasource}
onChange={onChange}
onRunQuery={this.onRunQuery}
/>
);
return <QueryEditor query={query} datasource={datasource} onChange={onChange} onRunQuery={this.onRunQuery} />;
}
return <div>Data source plugin does not export any Query Editor component</div>;
}
onToggleEditMode = () => {
const { angularScope } = this.state;
if (angularScope && angularScope.toggleEditorMode) {
angularScope.toggleEditorMode();
if (this.angularScope && this.angularScope.toggleEditorMode) {
this.angularScope.toggleEditorMode();
this.angularQueryEditor.digest();
}
@@ -146,11 +177,6 @@ export class QueryEditorRow extends PureComponent<Props, State> {
}
};
get hasTextEditMode() {
const { angularScope } = this.state;
return angularScope && angularScope.toggleEditorMode;
}
onRemoveQuery = () => {
this.props.onRemoveQuery(this.props.query);
};
@@ -167,10 +193,8 @@ export class QueryEditorRow extends PureComponent<Props, State> {
};
renderCollapsedText(): string | null {
const { angularScope } = this.state;
if (angularScope && angularScope.getCollapsedText) {
return angularScope.getCollapsedText();
if (this.angularScope && this.angularScope.getCollapsedText) {
return this.angularScope.getCollapsedText();
}
return null;
@@ -178,7 +202,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
render() {
const { query, inMixedMode } = this.props;
const { datasource, isCollapsed } = this.state;
const { datasource, isCollapsed, hasTextEditMode } = this.state;
const isDisabled = query.hide;
const bodyClasses = classNames('query-editor-row__body gf-form-query', {
@@ -208,7 +232,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
{isCollapsed && <div>{this.renderCollapsedText()}</div>}
</div>
<div className="query-editor-row__actions">
{this.hasTextEditMode && (
{hasTextEditMode && (
<button
className="query-editor-row__action"
onClick={this.onToggleEditMode}
@@ -244,10 +268,12 @@ export class QueryEditorRow extends PureComponent<Props, State> {
export interface AngularQueryComponentScope {
target: DataQuery;
panel: PanelModel;
dashboard: DashboardModel;
events: Emitter;
refresh: () => void;
render: () => void;
datasource: DataSourceApi;
toggleEditorMode?: () => void;
getCollapsedText?: () => string;
range: TimeRange;
}

View File

@@ -1,5 +1,5 @@
// Libraries
import React, { PureComponent } from 'react';
import React, { PureComponent, ChangeEvent, FocusEvent } from 'react';
// Utils
import { isValidTimeSpan } from 'app/core/utils/rangeutil';
@@ -9,7 +9,7 @@ import { Switch } from '@grafana/ui';
import { Input } from 'app/core/components/Form';
import { EventsWithValidation } from 'app/core/components/Form/Input';
import { InputStatus } from 'app/core/components/Form/Input';
import DataSourceOption from './DataSourceOption';
import { DataSourceOption } from './DataSourceOption';
import { FormLabel } from '@grafana/ui';
// Types
@@ -43,32 +43,79 @@ interface Props {
interface State {
relativeTime: string;
timeShift: string;
cacheTimeout: string;
maxDataPoints: string;
interval: string;
hideTimeOverride: boolean;
}
export class QueryOptions extends PureComponent<Props, State> {
allOptions = {
cacheTimeout: {
label: 'Cache timeout',
placeholder: '60',
name: 'cacheTimeout',
tooltipInfo: (
<>
If your time series store has a query cache this option can override the default cache timeout. Specify a
numeric value in seconds.
</>
),
},
maxDataPoints: {
label: 'Max data points',
placeholder: 'auto',
name: 'maxDataPoints',
tooltipInfo: (
<>
The maximum data points the query should return. For graphs this is automatically set to one data point per
pixel.
</>
),
},
minInterval: {
label: 'Min time interval',
placeholder: '0',
name: 'minInterval',
panelKey: 'interval',
tooltipInfo: (
<>
A lower limit for the auto group by time interval. Recommended to be set to write frequency, for example{' '}
<code>1m</code> if your data is written every minute. Access auto interval via variable{' '}
<code>$__interval</code> for time range string and <code>$__interval_ms</code> for numeric variable that can
be used in math expressions.
</>
),
},
};
constructor(props) {
super(props);
this.state = {
relativeTime: props.panel.timeFrom || '',
timeShift: props.panel.timeShift || '',
cacheTimeout: props.panel.cacheTimeout || '',
maxDataPoints: props.panel.maxDataPoints || '',
interval: props.panel.interval || '',
hideTimeOverride: props.panel.hideTimeOverride || false,
};
}
onRelativeTimeChange = event => {
onRelativeTimeChange = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({
relativeTime: event.target.value,
});
};
onTimeShiftChange = event => {
onTimeShiftChange = (event: ChangeEvent<HTMLInputElement>) => {
this.setState({
timeShift: event.target.value,
});
};
onOverrideTime = (evt, status: InputStatus) => {
const { value } = evt.target;
onOverrideTime = (event: FocusEvent<HTMLInputElement>, status: InputStatus) => {
const { value } = event.target;
const { panel } = this.props;
const emptyToNullValue = emptyToNull(value);
if (status === InputStatus.Valid && panel.timeFrom !== emptyToNullValue) {
@@ -77,8 +124,8 @@ export class QueryOptions extends PureComponent<Props, State> {
}
};
onTimeShift = (evt, status: InputStatus) => {
const { value } = evt.target;
onTimeShift = (event: FocusEvent<HTMLInputElement>, status: InputStatus) => {
const { value } = event.target;
const { panel } = this.props;
const emptyToNullValue = emptyToNull(value);
if (status === InputStatus.Valid && panel.timeShift !== emptyToNullValue) {
@@ -89,77 +136,49 @@ export class QueryOptions extends PureComponent<Props, State> {
onToggleTimeOverride = () => {
const { panel } = this.props;
panel.hideTimeOverride = !panel.hideTimeOverride;
this.setState({ hideTimeOverride: !this.state.hideTimeOverride }, () => {
panel.hideTimeOverride = this.state.hideTimeOverride;
panel.refresh();
});
};
onDataSourceOptionBlur = (panelKey: string) => () => {
const { panel } = this.props;
panel[panelKey] = this.state[panelKey];
panel.refresh();
};
renderOptions() {
const { datasource, panel } = this.props;
onDataSourceOptionChange = (panelKey: string) => (event: ChangeEvent<HTMLInputElement>) => {
this.setState({ ...this.state, [panelKey]: event.target.value });
};
renderOptions = () => {
const { datasource } = this.props;
const { queryOptions } = datasource.meta;
if (!queryOptions) {
return null;
}
const onChangeFn = (panelKey: string) => {
return (value: string | number) => {
panel[panelKey] = value;
panel.refresh();
};
};
const allOptions = {
cacheTimeout: {
label: 'Cache timeout',
placeholder: '60',
name: 'cacheTimeout',
value: panel.cacheTimeout,
tooltipInfo: (
<>
If your time series store has a query cache this option can override the default cache timeout. Specify a
numeric value in seconds.
</>
),
},
maxDataPoints: {
label: 'Max data points',
placeholder: 'auto',
name: 'maxDataPoints',
value: panel.maxDataPoints,
tooltipInfo: (
<>
The maximum data points the query should return. For graphs this is automatically set to one data point per
pixel.
</>
),
},
minInterval: {
label: 'Min time interval',
placeholder: '0',
name: 'minInterval',
value: panel.interval,
panelKey: 'interval',
tooltipInfo: (
<>
A lower limit for the auto group by time interval. Recommended to be set to write frequency, for example{' '}
<code>1m</code> if your data is written every minute. Access auto interval via variable{' '}
<code>$__interval</code> for time range string and <code>$__interval_ms</code> for numeric variable that can
be used in math expressions.
</>
),
},
};
return Object.keys(queryOptions).map(key => {
const options = allOptions[key];
return <DataSourceOption key={key} {...options} onChange={onChangeFn(allOptions[key].panelKey || key)} />;
const options = this.allOptions[key];
const panelKey = options.panelKey || key;
return (
<DataSourceOption
key={key}
{...options}
onChange={this.onDataSourceOptionChange(panelKey)}
onBlur={this.onDataSourceOptionBlur(panelKey)}
value={this.state[panelKey]}
/>
);
});
}
};
render() {
const hideTimeOverride = this.props.panel.hideTimeOverride;
const { hideTimeOverride } = this.state;
const { relativeTime, timeShift } = this.state;
return (
<div className="gf-form-inline">
{this.renderOptions()}
@@ -191,10 +210,11 @@ export class QueryOptions extends PureComponent<Props, State> {
value={timeShift}
/>
</div>
<div className="gf-form-inline">
<Switch label="Hide time info" checked={hideTimeOverride} onChange={this.onToggleTimeOverride} />
</div>
{(timeShift || relativeTime) && (
<div className="gf-form-inline">
<Switch label="Hide time info" checked={hideTimeOverride} onChange={this.onToggleTimeOverride} />
</div>
)}
</div>
);
}

View File

@@ -3,6 +3,9 @@ import React, { PureComponent } from 'react';
// Utils & Services
import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
import { StoreState } from 'app/types';
import { updateLocation } from 'app/core/actions';
// Components
import { EditorTabBody, EditorToolbarView } from './EditorTabBody';
@@ -11,9 +14,10 @@ import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
import { FadeIn } from 'app/core/components/Animations/FadeIn';
// Types
import { PanelModel } from '../state/PanelModel';
import { DashboardModel } from '../state/DashboardModel';
import { PanelModel } from '../state';
import { DashboardModel } from '../state';
import { PanelPlugin } from 'app/types/plugins';
import { VizPickerSearch } from './VizPickerSearch';
interface Props {
panel: PanelModel;
@@ -21,56 +25,53 @@ interface Props {
plugin: PanelPlugin;
angularPanel?: AngularComponent;
onTypeChanged: (newType: PanelPlugin) => void;
updateLocation: typeof updateLocation;
urlOpenVizPicker: boolean;
}
interface State {
isVizPickerOpen: boolean;
searchQuery: string;
scrollTop: number;
hasBeenFocused: boolean;
}
export class VisualizationTab extends PureComponent<Props, State> {
element: HTMLElement;
angularOptions: AngularComponent;
searchInput: HTMLElement;
constructor(props) {
super(props);
this.state = {
isVizPickerOpen: false,
isVizPickerOpen: this.props.urlOpenVizPicker,
hasBeenFocused: false,
searchQuery: '',
scrollTop: 0,
};
}
getPanelDefaultOptions = () => {
getReactPanelOptions = () => {
const { panel, plugin } = this.props;
if (plugin.exports.PanelDefaults) {
return panel.getOptions(plugin.exports.PanelDefaults.options);
}
return panel.getOptions(plugin.exports.PanelDefaults);
return panel.getOptions(plugin.exports.reactPanel.defaults);
};
renderPanelOptions() {
const { plugin, angularPanel } = this.props;
const { PanelOptions } = plugin.exports;
if (angularPanel) {
return <div ref={element => (this.element = element)} />;
}
return (
<>
{PanelOptions ? (
<PanelOptions options={this.getPanelDefaultOptions()} onChange={this.onPanelOptionsChanged} />
) : (
<p>Visualization has no options</p>
)}
</>
);
if (plugin.exports.reactPanel) {
const PanelEditor = plugin.exports.reactPanel.editor;
if (PanelEditor) {
return <PanelEditor options={this.getReactPanelOptions()} onOptionsChange={this.onPanelOptionsChanged} />;
}
}
return <p>Visualization has no options</p>;
}
componentDidMount() {
@@ -114,7 +115,12 @@ export class VisualizationTab extends PureComponent<Props, State> {
template +=
`
<div class="panel-options-group" ng-cloak>` +
(i > 0 ? `<div class="panel-options-group__header">{{ctrl.editorTabs[${i}].title}}</div>` : '') +
(i > 0
? `<div class="panel-options-group__header">
<span class="panel-options-group__title">{{ctrl.editorTabs[${i}].title}}
</span>
</div>`
: '') +
`<div class="panel-options-group__body">
<panel-editor-tab editor-tab="ctrl.editorTabs[${i}]" ctrl="ctrl"></panel-editor-tab>
</div>
@@ -139,6 +145,10 @@ export class VisualizationTab extends PureComponent<Props, State> {
}
}
clearQuery = () => {
this.setState({ searchQuery: '' });
};
onPanelOptionsChanged = (options: any) => {
this.props.panel.updateOptions(options);
this.forceUpdate();
@@ -149,11 +159,14 @@ export class VisualizationTab extends PureComponent<Props, State> {
};
onCloseVizPicker = () => {
this.setState({ isVizPickerOpen: false });
if (this.props.urlOpenVizPicker) {
this.props.updateLocation({ query: { openVizPicker: null }, partial: true });
}
this.setState({ isVizPickerOpen: false, hasBeenFocused: false });
};
onSearchQueryChange = evt => {
const value = evt.target.value;
onSearchQueryChange = (value: string) => {
this.setState({
searchQuery: value,
});
@@ -161,26 +174,16 @@ export class VisualizationTab extends PureComponent<Props, State> {
renderToolbar = (): JSX.Element => {
const { plugin } = this.props;
const { searchQuery } = this.state;
const { isVizPickerOpen, searchQuery } = this.state;
if (this.state.isVizPickerOpen) {
if (isVizPickerOpen) {
return (
<>
<label className="gf-form--has-input-icon">
<input
type="text"
className="gf-form-input width-13"
placeholder=""
onChange={this.onSearchQueryChange}
value={searchQuery}
ref={elem => elem && elem.focus()}
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
<button className="btn btn-link toolbar__close" onClick={this.onCloseVizPicker}>
<i className="fa fa-chevron-up" />
</button>
</>
<VizPickerSearch
plugin={plugin}
searchQuery={searchQuery}
onChange={this.onSearchQueryChange}
onClose={this.onCloseVizPicker}
/>
);
} else {
return (
@@ -219,10 +222,15 @@ export class VisualizationTab extends PureComponent<Props, State> {
};
return (
<EditorTabBody heading="Visualization" renderToolbar={this.renderToolbar} toolbarItems={[pluginHelp]}
scrollTop={scrollTop} setScrollTop={this.setScrollTop}>
<EditorTabBody
heading="Visualization"
renderToolbar={this.renderToolbar}
toolbarItems={[pluginHelp]}
scrollTop={scrollTop}
setScrollTop={this.setScrollTop}
>
<>
<FadeIn in={isVizPickerOpen} duration={200} unmountOnExit={true}>
<FadeIn in={isVizPickerOpen} duration={200} unmountOnExit={true} onExited={this.clearQuery}>
<VizTypePicker
current={plugin}
onTypeChanged={this.onTypeChanged}
@@ -236,3 +244,13 @@ export class VisualizationTab extends PureComponent<Props, State> {
);
}
}
const mapStateToProps = (state: StoreState) => ({
urlOpenVizPicker: !!state.location.query.openVizPicker,
});
const mapDispatchToProps = {
updateLocation,
};
export default connectWithStore(VisualizationTab, mapStateToProps, mapDispatchToProps);

View File

@@ -0,0 +1,33 @@
import React, { PureComponent } from 'react';
import { FilterInput } from 'app/core/components/FilterInput/FilterInput';
import { PanelPlugin } from 'app/types';
interface Props {
plugin: PanelPlugin;
searchQuery: string;
onChange: (query: string) => void;
onClose: () => void;
}
export class VizPickerSearch extends PureComponent<Props> {
render() {
const { searchQuery, onChange, onClose } = this.props;
return (
<>
<FilterInput
labelClassName="gf-form--has-input-icon"
inputClassName="gf-form-input width-13"
placeholder=""
onChange={onChange}
value={searchQuery}
ref={element => element && element.focus()}
/>
<button className="btn btn-link toolbar__close" onClick={onClose}>
<i className="fa fa-chevron-up" />
</button>
</>
);
}
}

View File

@@ -1,9 +1,9 @@
import React, { PureComponent } from 'react';
import _ from 'lodash';
import config from 'app/core/config';
import { PanelPlugin } from 'app/types/plugins';
import VizTypePickerPlugin from './VizTypePickerPlugin';
import { EmptySearchResult } from '@grafana/ui';
export interface Props {
current: PanelPlugin;
@@ -14,9 +14,9 @@ export interface Props {
export class VizTypePicker extends PureComponent<Props> {
searchInput: HTMLElement;
pluginList = this.getPanelPlugins('');
pluginList = this.getPanelPlugins;
constructor(props) {
constructor(props: Props) {
super(props);
}
@@ -25,14 +25,13 @@ export class VizTypePicker extends PureComponent<Props> {
return filteredPluginList.length - 1;
}
getPanelPlugins(filter): PanelPlugin[] {
const panels = _.chain(config.panels)
.filter({ hideFromList: false })
.map(item => item)
.value();
get getPanelPlugins(): PanelPlugin[] {
const allPanels = config.panels;
// add sort by sort property
return _.sortBy(panels, 'sort');
return Object.keys(allPanels)
.filter(key => allPanels[key]['hideFromList'] === false)
.map(key => allPanels[key])
.sort((a: PanelPlugin, b: PanelPlugin) => a.sort - b.sort);
}
renderVizPlugin = (plugin: PanelPlugin, index: number) => {
@@ -63,11 +62,15 @@ export class VizTypePicker extends PureComponent<Props> {
render() {
const filteredPluginList = this.getFilteredPluginList();
const hasResults = filteredPluginList.length > 0;
return (
<div className="viz-picker">
<div className="viz-picker-list">
{filteredPluginList.map((plugin, index) => this.renderVizPlugin(plugin, index))}
{hasResults ? (
filteredPluginList.map((plugin, index) => this.renderVizPlugin(plugin, index))
) : (
<EmptySearchResult>Could not find anything matching your query</EmptySearchResult>
)}
</div>
</div>
);

View File

@@ -1,25 +1,76 @@
import coreModule from 'app/core/core_module';
import { DashboardModel } from '../state/DashboardModel';
import { appEvents } from 'app/core/app_events';
import locationUtil from 'app/core/utils/location_util';
import { DashboardModel } from '../state/DashboardModel';
import { removePanel } from '../utils/panel';
export class DashboardSrv {
dash: any;
dashboard: DashboardModel;
/** @ngInject */
constructor(private backendSrv, private $rootScope, private $location) {}
constructor(private backendSrv, private $rootScope, private $location) {
appEvents.on('save-dashboard', this.saveDashboard.bind(this), $rootScope);
appEvents.on('panel-change-view', this.onPanelChangeView);
appEvents.on('remove-panel', this.onRemovePanel);
}
create(dashboard, meta) {
return new DashboardModel(dashboard, meta);
}
setCurrent(dashboard) {
this.dash = dashboard;
setCurrent(dashboard: DashboardModel) {
this.dashboard = dashboard;
}
getCurrent() {
return this.dash;
getCurrent(): DashboardModel {
return this.dashboard;
}
onRemovePanel = (panelId: number) => {
const dashboard = this.getCurrent();
removePanel(dashboard, dashboard.getPanelById(panelId), true);
};
onPanelChangeView = options => {
const urlParams = this.$location.search();
// handle toggle logic
if (options.fullscreen === urlParams.fullscreen) {
// I hate using these truthy converters (!!) but in this case
// I think it's appropriate. edit can be null/false/undefined and
// here i want all of those to compare the same
if (!!options.edit === !!urlParams.edit) {
delete urlParams.fullscreen;
delete urlParams.edit;
delete urlParams.panelId;
delete urlParams.tab;
this.$location.search(urlParams);
return;
}
}
if (options.fullscreen) {
urlParams.fullscreen = true;
} else {
delete urlParams.fullscreen;
}
if (options.edit) {
urlParams.edit = true;
} else {
delete urlParams.edit;
delete urlParams.tab;
}
if (options.panelId || options.panelId === 0) {
urlParams.panelId = options.panelId;
} else {
delete urlParams.panelId;
}
this.$location.search(urlParams);
};
handleSaveDashboardError(clone, options, err) {
options = options || {};
options.overwrite = true;
@@ -75,10 +126,10 @@ export class DashboardSrv {
}
postSave(clone, data) {
this.dash.version = data.version;
this.dashboard.version = data.version;
// important that these happens before location redirect below
this.$rootScope.appEvent('dashboard-saved', this.dash);
this.$rootScope.appEvent('dashboard-saved', this.dashboard);
this.$rootScope.appEvent('alert-success', ['Dashboard saved']);
const newUrl = locationUtil.stripBaseFromUrl(data.url);
@@ -88,12 +139,12 @@ export class DashboardSrv {
this.$location.url(newUrl).replace();
}
return this.dash;
return this.dashboard;
}
save(clone, options) {
options = options || {};
options.folderId = options.folderId >= 0 ? options.folderId : this.dash.meta.folderId || clone.folderId;
options.folderId = options.folderId >= 0 ? options.folderId : this.dashboard.meta.folderId || clone.folderId;
return this.backendSrv
.saveDashboard(clone, options)
@@ -103,26 +154,26 @@ export class DashboardSrv {
saveDashboard(options?, clone?) {
if (clone) {
this.setCurrent(this.create(clone, this.dash.meta));
this.setCurrent(this.create(clone, this.dashboard.meta));
}
if (this.dash.meta.provisioned) {
if (this.dashboard.meta.provisioned) {
return this.showDashboardProvisionedModal();
}
if (!this.dash.meta.canSave && options.makeEditable !== true) {
if (!this.dashboard.meta.canSave && options.makeEditable !== true) {
return Promise.resolve();
}
if (this.dash.title === 'New dashboard') {
if (this.dashboard.title === 'New dashboard') {
return this.showSaveAsModal();
}
if (this.dash.version > 0) {
if (this.dashboard.version > 0) {
return this.showSaveModal();
}
return this.save(this.dash.getSaveModelClone(), options);
return this.save(this.dashboard.getSaveModelClone(), options);
}
saveJSONDashboard(json: string) {
@@ -163,8 +214,8 @@ export class DashboardSrv {
}
return promise.then(res => {
if (this.dash && this.dash.id === dashboardId) {
this.dash.meta.isStarred = res;
if (this.dashboard && this.dashboard.id === dashboardId) {
this.dashboard.meta.isStarred = res;
}
return res;
});

View File

@@ -1,64 +0,0 @@
import config from 'app/core/config';
import { DashboardViewStateSrv } from './DashboardViewStateSrv';
import { DashboardModel } from '../state/DashboardModel';
describe('when updating view state', () => {
const location = {
replace: jest.fn(),
search: jest.fn(),
};
const $scope = {
appEvent: jest.fn(),
onAppEvent: jest.fn(() => {}),
dashboard: new DashboardModel({
panels: [{ id: 1 }],
}),
};
let viewState;
beforeEach(() => {
config.bootData = {
user: {
orgId: 1,
},
};
});
describe('to fullscreen true and edit true', () => {
beforeEach(() => {
location.search = jest.fn(() => {
return { fullscreen: true, edit: true, panelId: 1 };
});
viewState = new DashboardViewStateSrv($scope, location, {});
});
it('should update querystring and view state', () => {
const updateState = { fullscreen: true, edit: true, panelId: 1 };
viewState.update(updateState);
expect(location.search).toHaveBeenCalledWith({
edit: true,
editview: null,
fullscreen: true,
orgId: 1,
panelId: 1,
});
expect(viewState.dashboard.meta.fullscreen).toBe(true);
expect(viewState.state.fullscreen).toBe(true);
});
});
describe('to fullscreen false', () => {
beforeEach(() => {
viewState = new DashboardViewStateSrv($scope, location, {});
});
it('should remove params from query string', () => {
viewState.update({ fullscreen: true, panelId: 1, edit: true });
viewState.update({ fullscreen: false });
expect(viewState.state.fullscreen).toBe(null);
});
});
});

View File

@@ -1,185 +0,0 @@
import angular from 'angular';
import _ from 'lodash';
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import { DashboardModel } from '../state/DashboardModel';
// represents the transient view state
// like fullscreen panel & edit
export class DashboardViewStateSrv {
state: any;
panelScopes: any;
$scope: any;
dashboard: DashboardModel;
fullscreenPanel: any;
oldTimeRange: any;
/** @ngInject */
constructor($scope, private $location, private $timeout) {
const self = this;
self.state = {};
self.panelScopes = [];
self.$scope = $scope;
self.dashboard = $scope.dashboard;
$scope.onAppEvent('$routeUpdate', () => {
const urlState = self.getQueryStringState();
if (self.needsSync(urlState)) {
self.update(urlState, true);
}
});
$scope.onAppEvent('panel-change-view', (evt, payload) => {
self.update(payload);
});
// this marks changes to location during this digest cycle as not to add history item
// don't want url changes like adding orgId to add browser history
$location.replace();
this.update(this.getQueryStringState());
}
needsSync(urlState) {
return _.isEqual(this.state, urlState) === false;
}
getQueryStringState() {
const state = this.$location.search();
state.panelId = parseInt(state.panelId, 10) || null;
state.fullscreen = state.fullscreen ? true : null;
state.edit = state.edit === 'true' || state.edit === true || null;
state.editview = state.editview || null;
state.orgId = config.bootData.user.orgId;
return state;
}
serializeToUrl() {
const urlState = _.clone(this.state);
urlState.fullscreen = this.state.fullscreen ? true : null;
urlState.edit = this.state.edit ? true : null;
return urlState;
}
update(state, fromRouteUpdated?) {
// implement toggle logic
if (state.toggle) {
delete state.toggle;
if (this.state.fullscreen && state.fullscreen) {
if (this.state.edit === state.edit) {
state.fullscreen = !state.fullscreen;
}
}
}
_.extend(this.state, state);
if (!this.state.fullscreen) {
this.state.fullscreen = null;
this.state.edit = null;
// clear panel id unless in solo mode
if (!this.dashboard.meta.soloMode) {
this.state.panelId = null;
}
}
if ((this.state.fullscreen || this.dashboard.meta.soloMode) && this.state.panelId) {
// Trying to render panel in fullscreen when it's in the collapsed row causes an issue.
// So in this case expand collapsed row first.
this.toggleCollapsedPanelRow(this.state.panelId);
}
// if no edit state cleanup tab parm
if (!this.state.edit) {
delete this.state.tab;
}
// do not update url params if we are here
// from routeUpdated event
if (fromRouteUpdated !== true) {
this.$location.search(this.serializeToUrl());
}
this.syncState();
}
toggleCollapsedPanelRow(panelId) {
for (const panel of this.dashboard.panels) {
if (panel.collapsed) {
for (const rowPanel of panel.panels) {
if (rowPanel.id === panelId) {
this.dashboard.toggleRow(panel);
return;
}
}
}
}
}
syncState() {
if (this.state.fullscreen) {
const panel = this.dashboard.getPanelById(this.state.panelId);
if (!panel) {
this.state.fullscreen = null;
this.state.panelId = null;
this.state.edit = null;
this.update(this.state);
setTimeout(() => {
appEvents.emit('alert-error', ['Error', 'Panel not found']);
}, 100);
return;
}
if (!panel.fullscreen) {
this.enterFullscreen(panel);
} else if (this.dashboard.meta.isEditing !== this.state.edit) {
this.dashboard.setViewMode(panel, this.state.fullscreen, this.state.edit);
}
} else if (this.fullscreenPanel) {
this.leaveFullscreen();
}
}
leaveFullscreen() {
const panel = this.fullscreenPanel;
this.dashboard.setViewMode(panel, false, false);
delete this.fullscreenPanel;
this.$timeout(() => {
appEvents.emit('dash-scroll', { restore: true });
if (this.oldTimeRange !== this.dashboard.time) {
this.dashboard.startRefresh();
} else {
this.dashboard.render();
}
});
}
enterFullscreen(panel) {
const isEditing = this.state.edit && this.dashboard.meta.canEdit;
this.oldTimeRange = this.dashboard.time;
this.fullscreenPanel = panel;
// Firefox doesn't return scrollTop position properly if 'dash-scroll' is emitted after setViewMode()
this.$scope.appEvent('dash-scroll', { animate: false, pos: 0 });
this.dashboard.setViewMode(panel, true, isEditing);
}
}
/** @ngInject */
export function dashboardViewStateSrv($location, $timeout) {
return {
create: $scope => {
return new DashboardViewStateSrv($scope, $location, $timeout);
},
};
}
angular.module('grafana.services').factory('dashboardViewStateSrv', dashboardViewStateSrv);

View File

@@ -227,8 +227,8 @@ export class TimeSrv {
const timespan = range.to.valueOf() - range.from.valueOf();
const center = range.to.valueOf() - timespan / 2;
const to = center + timespan * factor / 2;
const from = center - timespan * factor / 2;
const to = center + (timespan * factor) / 2;
const from = center - (timespan * factor) / 2;
this.setTime({ from: moment.utc(from), to: moment.utc(to) });
}

View File

@@ -127,7 +127,7 @@ describe('DashboardModel', () => {
});
it('dashboard schema version should be set to latest', () => {
expect(model.schemaVersion).toBe(17);
expect(model.schemaVersion).toBe(18);
});
it('graph thresholds should be migrated', () => {

View File

@@ -22,7 +22,7 @@ export class DashboardMigrator {
let i, j, k, n;
const oldVersion = this.dashboard.schemaVersion;
const panelUpgrades = [];
this.dashboard.schemaVersion = 17;
this.dashboard.schemaVersion = 18;
if (oldVersion === this.dashboard.schemaVersion) {
return;
@@ -387,6 +387,36 @@ export class DashboardMigrator {
});
}
if (oldVersion < 18) {
// migrate change to gauge options
panelUpgrades.push(panel => {
if (panel['options-gauge']) {
panel.options = panel['options-gauge'];
panel.options.valueOptions = {
unit: panel.options.unit,
stat: panel.options.stat,
decimals: panel.options.decimals,
prefix: panel.options.prefix,
suffix: panel.options.suffix,
};
// correct order
if (panel.options.thresholds) {
panel.options.thresholds.reverse();
}
// this options prop was due to a bug
delete panel.options.options;
delete panel.options.unit;
delete panel.options.stat;
delete panel.options.decimals;
delete panel.options.prefix;
delete panel.options.suffix;
delete panel['options-gauge'];
}
});
}
if (panelUpgrades.length === 0) {
return;
}
@@ -457,7 +487,7 @@ export class DashboardMigrator {
for (const panel of row.panels) {
panel.span = panel.span || DEFAULT_PANEL_SPAN;
if (panel.minSpan) {
panel.minSpan = Math.min(GRID_COLUMN_COUNT, GRID_COLUMN_COUNT / 12 * panel.minSpan);
panel.minSpan = Math.min(GRID_COLUMN_COUNT, (GRID_COLUMN_COUNT / 12) * panel.minSpan);
}
const panelWidth = Math.floor(panel.span) * widthFactor;
const panelHeight = panel.height ? getGridHeight(panel.height) : rowGridHeight;

View File

@@ -635,4 +635,32 @@ describe('DashboardModel', () => {
expect(saveModel.templating.list[0].filters[0].value).toBe('server 1');
});
});
describe('Given a dashboard with one panel legend on and two off', () => {
let model;
beforeEach(() => {
const data = {
panels: [
{ id: 1, type: 'graph', gridPos: { x: 0, y: 0, w: 24, h: 2 }, legend: { show: true } },
{ id: 3, type: 'graph', gridPos: { x: 0, y: 4, w: 12, h: 2 }, legend: { show: false } },
{ id: 4, type: 'graph', gridPos: { x: 12, y: 4, w: 12, h: 2 }, legend: { show: false } },
],
};
model = new DashboardModel(data);
});
it('toggleLegendsForAll should toggle all legends on on first execution', () => {
model.toggleLegendsForAll();
const legendsOn = model.panels.filter(panel => panel.legend.show === true);
expect(legendsOn.length).toBe(3);
});
it('toggleLegendsForAll should toggle all legends off on second execution', () => {
model.toggleLegendsForAll();
model.toggleLegendsForAll();
const legendsOn = model.panels.filter(panel => panel.legend.show === true);
expect(legendsOn.length).toBe(0);
});
});
});

View File

@@ -1,20 +1,26 @@
// Libaries
import moment from 'moment';
import _ from 'lodash';
import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
// Constants
import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
import { GRID_COLUMN_COUNT, REPEAT_DIR_VERTICAL, GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
// Utils & Services
import { Emitter } from 'app/core/utils/emitter';
import { contextSrv } from 'app/core/services/context_srv';
import sortByKeys from 'app/core/utils/sort_by_keys';
// Types
import { PanelModel } from './PanelModel';
import { DashboardMigrator } from './DashboardMigrator';
import { TimeRange } from '@grafana/ui/src';
import { UrlQueryValue, KIOSK_MODE_TV, DashboardMeta } from 'app/types';
export class DashboardModel {
id: any;
uid: any;
title: any;
uid: string;
title: string;
autoUpdate: any;
description: any;
tags: any;
@@ -43,7 +49,7 @@ export class DashboardModel {
// repeat process cycles
iteration: number;
meta: any;
meta: DashboardMeta;
events: Emitter;
static nonPersistedProperties: { [str: string]: boolean } = {
@@ -127,6 +133,8 @@ export class DashboardModel {
meta.canEdit = meta.canEdit !== false;
meta.showSettings = meta.canEdit;
meta.canMakeEditable = meta.canSave && !this.editable;
meta.fullscreen = false;
meta.isEditing = false;
if (!this.editable) {
meta.canEdit = false;
@@ -860,11 +868,7 @@ export class DashboardModel {
return !_.isEqual(updated, this.originalTemplating);
}
autoFitPanels(viewHeight: number) {
if (!this.meta.autofitpanels) {
return;
}
autoFitPanels(viewHeight: number, kioskMode?: UrlQueryValue) {
const currentGridHeight = Math.max(
...this.panels.map(panel => {
return panel.gridPos.h + panel.gridPos.y;
@@ -878,13 +882,13 @@ export class DashboardModel {
let visibleHeight = viewHeight - navbarHeight - margin;
// Remove submenu height if visible
if (this.meta.submenuEnabled && !this.meta.kiosk) {
if (this.meta.submenuEnabled && !kioskMode) {
visibleHeight -= submenuHeight;
}
// add back navbar height
if (this.meta.kiosk === 'b') {
visibleHeight += 55;
if (kioskMode && kioskMode !== KIOSK_MODE_TV) {
visibleHeight += navbarHeight;
}
const visibleGridHeight = Math.floor(visibleHeight / (GRID_CELL_HEIGHT + GRID_CELL_VMARGIN));
@@ -895,4 +899,38 @@ export class DashboardModel {
panel.gridPos.h = Math.round(panel.gridPos.h / scaleFactor) || 1;
});
}
templateVariableValueUpdated() {
this.processRepeats();
this.events.emit('template-variable-value-updated');
}
expandParentRowFor(panelId: number) {
for (const panel of this.panels) {
if (panel.collapsed) {
for (const rowPanel of panel.panels) {
if (rowPanel.id === panelId) {
this.toggleRow(panel);
return;
}
}
}
}
}
toggleLegendsForAll() {
const panelsWithLegends = this.panels.filter(panel => {
return panel.legend !== undefined && panel.legend !== null;
});
// determine if more panels are displaying legends or not
const onCount = panelsWithLegends.filter(panel => panel.legend.show).length;
const offCount = panelsWithLegends.length - onCount;
const panelLegendsOn = onCount >= offCount;
for (const panel of panelsWithLegends) {
panel.legend.show = !panelLegendsOn;
panel.render();
}
}
}

View File

@@ -1,5 +1,4 @@
import _ from 'lodash';
import { PanelModel } from '../state/PanelModel';
import { PanelModel } from './PanelModel';
describe('PanelModel', () => {
describe('when creating new panel model', () => {
@@ -9,10 +8,21 @@ describe('PanelModel', () => {
model = new PanelModel({
type: 'table',
showColumns: true,
targets: [
{refId: 'A'},
{noRefId: true}
]
targets: [{ refId: 'A' }, { noRefId: true }],
options: {
thresholds: [
{
color: '#F2495C',
index: 1,
value: 50,
},
{
color: '#73BF69',
index: 0,
value: null,
},
],
},
});
});
@@ -38,9 +48,24 @@ describe('PanelModel', () => {
expect(saveModel.events).toBe(undefined);
});
it('should restore -Infinity value for base threshold', () => {
expect(model.options.thresholds).toEqual([
{
color: '#F2495C',
index: 1,
value: 50,
},
{
color: '#73BF69',
index: 0,
value: -Infinity,
},
]);
});
describe('when changing panel type', () => {
beforeEach(() => {
model.changeType('graph', true);
model.changeType('graph');
model.alert = { id: 2 };
});
@@ -49,14 +74,28 @@ describe('PanelModel', () => {
});
it('should restore table properties when changing back', () => {
model.changeType('table', true);
model.changeType('table');
expect(model.showColumns).toBe(true);
});
it('should remove alert rule when changing type that does not support it', () => {
model.changeType('table', true);
model.changeType('table');
expect(model.alert).toBe(undefined);
});
});
describe('get panel options', () => {
it('should apply defaults', () => {
model.options = { existingProp: 10 };
const options = model.getOptions({
defaultProp: true,
existingProp: 0,
});
expect(options.defaultProp).toBe(true);
expect(options.existingProp).toBe(10);
expect(model.options).toBe(options);
});
});
});
});

View File

@@ -3,8 +3,7 @@ import _ from 'lodash';
// Types
import { Emitter } from 'app/core/utils/emitter';
import { PANEL_OPTIONS_KEY_PREFIX } from 'app/core/constants';
import { DataQuery, TimeSeries } from '@grafana/ui';
import { DataQuery, TimeSeries, Threshold, ScopedVars } from '@grafana/ui';
import { TableData } from '@grafana/ui/src';
export interface GridPos {
@@ -47,8 +46,6 @@ const mustKeepProps: { [str: string]: boolean } = {
timeFrom: true,
timeShift: true,
hideTimeOverride: true,
maxDataPoints: true,
interval: true,
description: true,
links: true,
fullscreen: true,
@@ -74,7 +71,7 @@ export class PanelModel {
type: string;
title: string;
alert?: any;
scopedVars?: any;
scopedVars?: ScopedVars;
repeat?: string;
repeatIteration?: number;
repeatPanelId?: number;
@@ -92,6 +89,9 @@ export class PanelModel {
timeFrom?: any;
timeShift?: any;
hideTimeOverride?: any;
options: {
[key: string]: any;
};
maxDataPoints?: number;
interval?: string;
@@ -105,9 +105,8 @@ export class PanelModel {
hasRefreshed: boolean;
events: Emitter;
cacheTimeout?: any;
// cache props between plugins
cachedPluginOptions?: any;
legend?: { show: boolean };
constructor(model) {
this.events = new Emitter();
@@ -121,6 +120,8 @@ export class PanelModel {
_.defaultsDeep(this, _.cloneDeep(defaults));
// queries must have refId
this.ensureQueryIds();
this.restoreInfintyForThresholds();
}
ensureQueryIds() {
@@ -133,21 +134,28 @@ export class PanelModel {
}
}
restoreInfintyForThresholds() {
if (this.options && this.options.thresholds) {
this.options.thresholds = this.options.thresholds.map((threshold: Threshold) => {
// JSON serialization of -Infinity is 'null' so lets convert it back to -Infinity
if (threshold.index === 0 && threshold.value === null) {
return { ...threshold, value: -Infinity };
}
return threshold;
});
}
}
getOptions(panelDefaults) {
return _.defaultsDeep(this[this.getOptionsKey()] || {}, panelDefaults);
return _.defaultsDeep(this.options || {}, panelDefaults);
}
updateOptions(options: object) {
const update: any = {};
update[this.getOptionsKey()] = options;
Object.assign(this, update);
this.options = options;
this.render();
}
private getOptionsKey() {
return PANEL_OPTIONS_KEY_PREFIX + this.type;
}
getSaveModel() {
const model: any = {};
for (const property in this) {
@@ -221,10 +229,6 @@ export class PanelModel {
}, {});
}
private saveCurrentPanelOptions() {
this.cachedPluginOptions[this.type] = this.getOptionsToRemember();
}
private restorePanelOptions(pluginId: string) {
const prevOptions = this.cachedPluginOptions[pluginId] || {};
@@ -233,24 +237,28 @@ export class PanelModel {
});
}
changeType(pluginId: string, fromAngularPanel: boolean) {
this.saveCurrentPanelOptions();
changeType(pluginId: string, preserveOptions?: any) {
const oldOptions: any = this.getOptionsToRemember();
const oldPluginId = this.type;
this.type = pluginId;
// for angular panels only we need to remove all events and let angular panels do some cleanup
if (fromAngularPanel) {
this.destroy();
for (const key of _.keys(this)) {
if (mustKeepProps[key]) {
continue;
}
delete this[key];
// remove panel type specific options
for (const key of _.keys(this)) {
if (mustKeepProps[key]) {
continue;
}
delete this[key];
}
this.cachedPluginOptions[oldPluginId] = oldOptions;
this.restorePanelOptions(pluginId);
if (preserveOptions && oldOptions) {
this.options = this.options || {};
Object.assign(this.options, preserveOptions(oldPluginId, oldOptions.options));
}
}
addQuery(query?: Partial<DataQuery>) {

View File

@@ -1,39 +1,43 @@
import { StoreState } from 'app/types';
import { ThunkAction } from 'redux-thunk';
// Services & Utils
import { getBackendSrv } from 'app/core/services/backend_srv';
import appEvents from 'app/core/app_events';
import { actionCreatorFactory, noPayloadActionCreatorFactory } from 'app/core/redux';
import { createSuccessNotification } from 'app/core/copy/appNotification';
// Actions
import { loadPluginDashboards } from '../../plugins/state/actions';
import { notifyApp } from 'app/core/actions';
// Types
import {
ThunkResult,
DashboardAcl,
DashboardAclDTO,
PermissionLevel,
DashboardAclUpdateDTO,
NewDashboardAclItem,
} from 'app/types/acl';
MutableDashboard,
DashboardInitError,
} from 'app/types';
export enum ActionTypes {
LoadDashboardPermissions = 'LOAD_DASHBOARD_PERMISSIONS',
LoadStarredDashboards = 'LOAD_STARRED_DASHBOARDS',
}
export const loadDashboardPermissions = actionCreatorFactory<DashboardAclDTO[]>('LOAD_DASHBOARD_PERMISSIONS').create();
export interface LoadDashboardPermissionsAction {
type: ActionTypes.LoadDashboardPermissions;
payload: DashboardAcl[];
}
export const dashboardInitFetching = noPayloadActionCreatorFactory('DASHBOARD_INIT_FETCHING').create();
export interface LoadStarredDashboardsAction {
type: ActionTypes.LoadStarredDashboards;
payload: DashboardAcl[];
}
export const dashboardInitServices = noPayloadActionCreatorFactory('DASHBOARD_INIT_SERVICES').create();
export type Action = LoadDashboardPermissionsAction | LoadStarredDashboardsAction;
export const dashboardInitSlow = noPayloadActionCreatorFactory('SET_DASHBOARD_INIT_SLOW').create();
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, any>;
export const dashboardInitCompleted = actionCreatorFactory<MutableDashboard>('DASHBOARD_INIT_COMLETED').create();
export const loadDashboardPermissions = (items: DashboardAclDTO[]): LoadDashboardPermissionsAction => ({
type: ActionTypes.LoadDashboardPermissions,
payload: items,
});
/*
* Unrecoverable init failure (fetch or model creation failed)
*/
export const dashboardInitFailed = actionCreatorFactory<DashboardInitError>('DASHBOARD_INIT_FAILED').create();
/*
* When leaving dashboard, resets state
* */
export const cleanUpDashboard = noPayloadActionCreatorFactory('DASHBOARD_CLEAN_UP').create();
export function getDashboardPermissions(id: number): ThunkResult<void> {
return async dispatch => {
@@ -124,7 +128,7 @@ export function addDashboardPermission(dashboardId: number, newItem: NewDashboar
export function importDashboard(data, dashboardTitle: string): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().post('/api/dashboards/import', data);
appEvents.emit('alert-success', ['Dashboard Imported', dashboardTitle]);
dispatch(notifyApp(createSuccessNotification('Dashboard Imported', dashboardTitle)));
dispatch(loadPluginDashboards());
};
}

View File

@@ -0,0 +1,148 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { initDashboard, InitDashboardArgs } from './initDashboard';
import { DashboardRouteInfo } from 'app/types';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { dashboardInitFetching, dashboardInitCompleted, dashboardInitServices } from './actions';
jest.mock('app/core/services/backend_srv');
const mockStore = configureMockStore([thunk]);
interface ScenarioContext {
args: InitDashboardArgs;
timeSrv: any;
annotationsSrv: any;
unsavedChangesSrv: any;
variableSrv: any;
dashboardSrv: any;
keybindingSrv: any;
backendSrv: any;
setup: (fn: () => void) => void;
actions: any[];
storeState: any;
}
type ScenarioFn = (ctx: ScenarioContext) => void;
function describeInitScenario(description: string, scenarioFn: ScenarioFn) {
describe(description, () => {
const timeSrv = { init: jest.fn() };
const annotationsSrv = { init: jest.fn() };
const unsavedChangesSrv = { init: jest.fn() };
const variableSrv = { init: jest.fn() };
const dashboardSrv = { setCurrent: jest.fn() };
const keybindingSrv = { setupDashboardBindings: jest.fn() };
const injectorMock = {
get: (name: string) => {
switch (name) {
case 'timeSrv':
return timeSrv;
case 'annotationsSrv':
return annotationsSrv;
case 'unsavedChangesSrv':
return unsavedChangesSrv;
case 'dashboardSrv':
return dashboardSrv;
case 'variableSrv':
return variableSrv;
case 'keybindingSrv':
return keybindingSrv;
default:
throw { message: 'Unknown service ' + name };
}
},
};
let setupFn = () => {};
const ctx: ScenarioContext = {
args: {
$injector: injectorMock,
$scope: {},
fixUrl: false,
routeInfo: DashboardRouteInfo.Normal,
},
backendSrv: getBackendSrv(),
timeSrv,
annotationsSrv,
unsavedChangesSrv,
variableSrv,
dashboardSrv,
keybindingSrv,
actions: [],
storeState: {
location: {
query: {},
},
user: {},
},
setup: (fn: () => void) => {
setupFn = fn;
},
};
beforeEach(async () => {
setupFn();
const store = mockStore(ctx.storeState);
await store.dispatch(initDashboard(ctx.args));
ctx.actions = store.getActions();
});
scenarioFn(ctx);
});
}
describeInitScenario('Initializing new dashboard', ctx => {
ctx.setup(() => {
ctx.storeState.user.orgId = 12;
ctx.args.routeInfo = DashboardRouteInfo.New;
});
it('Should send action dashboardInitFetching', () => {
expect(ctx.actions[0].type).toBe(dashboardInitFetching.type);
});
it('Should send action dashboardInitServices ', () => {
expect(ctx.actions[1].type).toBe(dashboardInitServices.type);
});
it('Should update location with orgId query param', () => {
expect(ctx.actions[2].type).toBe('UPDATE_LOCATION');
expect(ctx.actions[2].payload.query.orgId).toBe(12);
});
it('Should send action dashboardInitCompleted', () => {
expect(ctx.actions[3].type).toBe(dashboardInitCompleted.type);
expect(ctx.actions[3].payload.title).toBe('New dashboard');
});
it('Should Initializing services', () => {
expect(ctx.timeSrv.init).toBeCalled();
expect(ctx.annotationsSrv.init).toBeCalled();
expect(ctx.variableSrv.init).toBeCalled();
expect(ctx.unsavedChangesSrv.init).toBeCalled();
expect(ctx.keybindingSrv.setupDashboardBindings).toBeCalled();
expect(ctx.dashboardSrv.setCurrent).toBeCalled();
});
});
describeInitScenario('Initializing home dashboard', ctx => {
ctx.setup(() => {
ctx.args.routeInfo = DashboardRouteInfo.Home;
ctx.backendSrv.get.mockReturnValue(
Promise.resolve({
redirectUri: '/u/123/my-home',
})
);
});
it('Should redirect to custom home dashboard', () => {
expect(ctx.actions[1].type).toBe('UPDATE_LOCATION');
expect(ctx.actions[1].payload.path).toBe('/u/123/my-home');
});
});

View File

@@ -0,0 +1,233 @@
// Services & Utils
import { createErrorNotification } from 'app/core/copy/appNotification';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { DashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { TimeSrv } from 'app/features/dashboard/services/TimeSrv';
import { AnnotationsSrv } from 'app/features/annotations/annotations_srv';
import { VariableSrv } from 'app/features/templating/variable_srv';
import { KeybindingSrv } from 'app/core/services/keybindingSrv';
// Actions
import { updateLocation } from 'app/core/actions';
import { notifyApp } from 'app/core/actions';
import locationUtil from 'app/core/utils/location_util';
import {
dashboardInitFetching,
dashboardInitCompleted,
dashboardInitFailed,
dashboardInitSlow,
dashboardInitServices,
} from './actions';
// Types
import { DashboardRouteInfo, StoreState, ThunkDispatch, ThunkResult, DashboardDTO } from 'app/types';
import { DashboardModel } from './DashboardModel';
export interface InitDashboardArgs {
$injector: any;
$scope: any;
urlUid?: string;
urlSlug?: string;
urlType?: string;
urlFolderId?: string;
routeInfo: DashboardRouteInfo;
fixUrl: boolean;
}
async function redirectToNewUrl(slug: string, dispatch: ThunkDispatch, currentPath: string) {
const res = await getBackendSrv().getDashboardBySlug(slug);
if (res) {
let newUrl = res.meta.url;
// fix solo route urls
if (currentPath.indexOf('dashboard-solo') !== -1) {
newUrl = newUrl.replace('/d/', '/d-solo/');
}
const url = locationUtil.stripBaseFromUrl(newUrl);
dispatch(updateLocation({ path: url, partial: true, replace: true }));
}
}
async function fetchDashboard(
args: InitDashboardArgs,
dispatch: ThunkDispatch,
getState: () => StoreState
): Promise<DashboardDTO | null> {
try {
switch (args.routeInfo) {
case DashboardRouteInfo.Home: {
// load home dash
const dashDTO: DashboardDTO = await getBackendSrv().get('/api/dashboards/home');
// if user specified a custom home dashboard redirect to that
if (dashDTO.redirectUri) {
const newUrl = locationUtil.stripBaseFromUrl(dashDTO.redirectUri);
dispatch(updateLocation({ path: newUrl, replace: true }));
return null;
}
// disable some actions on the default home dashboard
dashDTO.meta.canSave = false;
dashDTO.meta.canShare = false;
dashDTO.meta.canStar = false;
return dashDTO;
}
case DashboardRouteInfo.Normal: {
// for old db routes we redirect
if (args.urlType === 'db') {
redirectToNewUrl(args.urlSlug, dispatch, getState().location.path);
return null;
}
const loaderSrv: DashboardLoaderSrv = args.$injector.get('dashboardLoaderSrv');
const dashDTO: DashboardDTO = await loaderSrv.loadDashboard(args.urlType, args.urlSlug, args.urlUid);
if (args.fixUrl && dashDTO.meta.url) {
// check if the current url is correct (might be old slug)
const dashboardUrl = locationUtil.stripBaseFromUrl(dashDTO.meta.url);
const currentPath = getState().location.path;
if (dashboardUrl !== currentPath) {
// replace url to not create additional history items and then return so that initDashboard below isn't executed multiple times.
dispatch(updateLocation({ path: dashboardUrl, partial: true, replace: true }));
return null;
}
}
return dashDTO;
}
case DashboardRouteInfo.New: {
return getNewDashboardModelData(args.urlFolderId);
}
default:
throw { message: 'Unknown route ' + args.routeInfo };
}
} catch (err) {
dispatch(dashboardInitFailed({ message: 'Failed to fetch dashboard', error: err }));
console.log(err);
return null;
}
}
/**
* This action (or saga) does everything needed to bootstrap a dashboard & dashboard model.
* First it handles the process of fetching the dashboard, correcting the url if required (causing redirects/url updates)
*
* This is used both for single dashboard & solo panel routes, home & new dashboard routes.
*
* Then it handles the initializing of the old angular services that the dashboard components & panels still depend on
*
*/
export function initDashboard(args: InitDashboardArgs): ThunkResult<void> {
return async (dispatch, getState) => {
// set fetching state
dispatch(dashboardInitFetching());
// Detect slow loading / initializing and set state flag
// This is in order to not show loading indication for fast loading dashboards as it creates blinking/flashing
setTimeout(() => {
if (getState().dashboard.model === null) {
dispatch(dashboardInitSlow());
}
}, 500);
// fetch dashboard data
const dashDTO = await fetchDashboard(args, dispatch, getState);
// returns null if there was a redirect or error
if (!dashDTO) {
return;
}
// set initializing state
dispatch(dashboardInitServices());
// create model
let dashboard: DashboardModel;
try {
dashboard = new DashboardModel(dashDTO.dashboard, dashDTO.meta);
} catch (err) {
dispatch(dashboardInitFailed({ message: 'Failed create dashboard model', error: err }));
console.log(err);
return;
}
// add missing orgId query param
const storeState = getState();
if (!storeState.location.query.orgId) {
dispatch(updateLocation({ query: { orgId: storeState.user.orgId }, partial: true, replace: true }));
}
// init services
const timeSrv: TimeSrv = args.$injector.get('timeSrv');
const annotationsSrv: AnnotationsSrv = args.$injector.get('annotationsSrv');
const variableSrv: VariableSrv = args.$injector.get('variableSrv');
const keybindingSrv: KeybindingSrv = args.$injector.get('keybindingSrv');
const unsavedChangesSrv = args.$injector.get('unsavedChangesSrv');
const dashboardSrv: DashboardSrv = args.$injector.get('dashboardSrv');
timeSrv.init(dashboard);
annotationsSrv.init(dashboard);
// template values service needs to initialize completely before
// the rest of the dashboard can load
try {
await variableSrv.init(dashboard);
} catch (err) {
dispatch(notifyApp(createErrorNotification('Templating init failed', err)));
console.log(err);
}
try {
dashboard.processRepeats();
dashboard.updateSubmenuVisibility();
// handle auto fix experimental feature
const queryParams = getState().location.query;
if (queryParams.autofitpanels) {
dashboard.autoFitPanels(window.innerHeight, queryParams.kiosk);
}
// init unsaved changes tracking
unsavedChangesSrv.init(dashboard, args.$scope);
keybindingSrv.setupDashboardBindings(args.$scope, dashboard);
} catch (err) {
dispatch(notifyApp(createErrorNotification('Dashboard init failed', err)));
console.log(err);
}
// legacy srv state
dashboardSrv.setCurrent(dashboard);
// yay we are done
dispatch(dashboardInitCompleted(dashboard));
};
}
function getNewDashboardModelData(urlFolderId?: string): any {
const data = {
meta: {
canStar: false,
canShare: false,
isNew: true,
folderId: 0,
},
dashboard: {
title: 'New dashboard',
panels: [
{
type: 'add-panel',
gridPos: { x: 0, y: 0, w: 12, h: 9 },
title: 'Panel Title',
},
],
},
};
if (urlFolderId) {
data.meta.folderId = parseInt(urlFolderId, 10);
}
return data;
}

Some files were not shown because too many files have changed in this diff Show More