Merge branch 'master' into poc_token_auth

* master: (32 commits)
  Fixed react key warning for loki start page
  Disable query should trigger refresh
  added docs entry for check_for_updates config flag, fixes ##14940
  Explore: Fix scanning for logs
  Moved ad hoc filters and upload directive
  Moved dashboard srv and snapshot ctrl
  Moved share modal
  Moved dashboard save modals to components folder
  Moved unsaved changes service and modal
  Removed unused alertingSrv
  Moved view state srv to services
  Moved timepicker to components
  Moved submenu into components dir
  Moved dashboard settings to components
  Moved dashboard permissions into components dir
  Moved history component, added start draft of frontend code style guide
  fix: Use custom whitelist for XSS sanitizer to allow class and style attributes
  Began work on improving structure and organization of components under features/dashboard, #14062
  Fix a typo in changelog
  Update ROADMAP.md
  ...
This commit is contained in:
bergquist 2019-01-25 08:28:24 +01:00
commit b5572b23b6
98 changed files with 439 additions and 198 deletions

View File

@ -12,7 +12,7 @@
* **Auth**: Prevent password reset when login form is disabled or either LDAP or Auth Proxy is enabled [#14246](https://github.com/grafana/grafana/issues/14246), thx [@SilverFire](https://github.com/SilverFire)
* **Dataproxy**: Override incoming Authorization header [#13815](https://github.com/grafana/grafana/issues/13815), thx [@kornholi](https://github.com/kornholi)
* **Admin**: Fix prevent removing last grafana admin permissions [#11067](https://github.com/grafana/grafana/issues/11067), thx [@danielbh](https://github.com/danielbh)
* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK]req(https://github.com/IntegersOfK)
* **Templating**: Escaping "Custom" template variables [#13754](https://github.com/grafana/grafana/issues/13754), thx [@IntegersOfK](https://github.com/IntegersOfK)
* **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483)
* **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548)
* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)

View File

@ -5,18 +5,22 @@ But it will give you an idea of our current vision and plan.
### Short term (1-2 months)
- PRs & Bugs
- Multi-Stat panel
- React Panel Support
- React Query Editor Support
- Metrics & Log Explore UI
- Grafana UI library shared between grafana & plugins
- Seperate visualization from panels
- More reuse between Explore & dashboard
- Explore logging support for more data sources
### Mid term (2-4 months)
- React Panels
- Change visualization (panel type) on the fly.
- Templating Query Editor UI Plugin hook
- Backend plugins
- Drilldown links
- Dashboards as code workflows
- React migration
- New panels
### Long term (4 - 8 months)
- Alerting improvements (silence, per series tracking, etc)
- Progress on React migration
### In a distant future far far away
- Meta queries

View File

@ -188,8 +188,8 @@ Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
"defaultRegion": "us-west-1"
},
"secureJsonData": {
"accessKey": "Ol4pIDpeKSA6XikgOl4p", //should not be encoded
"secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs" //should be Base-64 encoded
"accessKey": "Ol4pIDpeKSA6XikgOl4p",
"secretKey": "dGVzdCBrZXkgYmxlYXNlIGRvbid0IHN0ZWFs"
}
}
```

View File

@ -391,6 +391,12 @@ value is `true`.
If you want to track Grafana usage via Google analytics specify *your* Universal
Analytics ID here. By default this feature is disabled.
### check_for_updates
Set to false to disable all checks to https://grafana.com for new versions of Grafana and installed plugins. Check is used
in some UI views to notify that a Grafana or plugin update exists. This option does not cause any auto updates, nor
send any sensitive information.
<hr />
## [dashboards]

View File

@ -52,6 +52,7 @@ Filter Option | Example | Raw | Interpolated | Description
`csv`| ${servers:csv} | `'test1', 'test2'` | `test1,test2` | Formats multi-value variable as a comma-separated string
`distributed`| ${servers:distributed} | `'test1', 'test2'` | `test1,servers=test2` | Formats multi-value variable in custom format for OpenTSDB.
`lucene`| ${servers:lucene} | `'test', 'test2'` | `("test" OR "test2")` | Formats multi-value variable as a lucene expression.
`percentencode` | ${servers:percentencode} | `'foo()bar BAZ', 'test2'` | `{foo%28%29bar%20BAZ%2Ctest2}` | Formats multi-value variable into a glob, percent-encoded.
Test the formatting options on the [Grafana Play site](http://play.grafana.org/d/cJtIfcWiz/template-variable-formatting-options?orgId=1).

View File

@ -336,7 +336,7 @@ func addGettingStartedPanelToHomeDashboard(dash *simplejson.Json) {
"id": 123123,
"gridPos": map[string]interface{}{
"x": 0,
"y": 3,
"y": 0,
"w": 24,
"h": 4,
},

View File

@ -236,7 +236,7 @@ export class KeybindingSrv {
shareScope.dashboard = dashboard;
appEvents.emit('show-modal', {
src: 'public/app/features/dashboard/partials/shareModal.html',
src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: shareScope,
});
}

View File

@ -44,9 +44,25 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[]
return matches;
}
const XSSWL = Object.keys(xss.whiteList).reduce((acc, element) => {
acc[element] = xss.whiteList[element].concat(['class', 'style']);
return acc;
}, {});
const sanitizeXSS = new xss.FilterXSS({
whiteList: XSSWL
});
/**
* Returns string safe from XSS attacks.
*
* Even though we allow the style-attribute, there's still default filtering applied to it
* Info: https://github.com/leizongmin/js-xss#customize-css-filter
* Whitelist: https://github.com/leizongmin/js-css-filter/blob/master/lib/default.js
*/
export function sanitize (unsanitizedString: string): string {
try {
return xss(unsanitizedString);
return sanitizeXSS.process(unsanitizedString);
} catch (error) {
console.log('String could not be sanitized', unsanitizedString);
return unsanitizedString;

View File

@ -1,7 +1,7 @@
import './annotations/all';
import './templating/all';
import './plugins/all';
import './dashboard/all';
import './dashboard';
import './playlist/all';
import './panel/all';
import './org/all';

View File

@ -1,13 +0,0 @@
import coreModule from 'app/core/core_module';
export class AlertingSrv {
dashboard: any;
alerts: any[];
init(dashboard, alerts) {
this.dashboard = dashboard;
this.alerts = alerts || [];
}
}
coreModule.service('alertingSrv', AlertingSrv);

View File

@ -1,45 +0,0 @@
import './dashboard_ctrl';
import './alerting_srv';
import './history/history';
import './dashboard_loader_srv';
import './dashnav/dashnav';
import './submenu/submenu';
import './save_as_modal';
import './save_modal';
import './save_provisioned_modal';
import './shareModalCtrl';
import './share_snapshot_ctrl';
import './dashboard_srv';
import './view_state_srv';
import './validation_srv';
import './time_srv';
import './unsaved_changes_srv';
import './unsaved_changes_modal';
import './timepicker/timepicker';
import './upload';
import './export/export_modal';
import './export_data/export_data_modal';
import './ad_hoc_filters';
import './repeat_option/repeat_option';
import './dashgrid/DashboardGridDirective';
import './dashgrid/RowOptions';
import './folder_picker/folder_picker';
import './move_to_folder_modal/move_to_folder';
import './settings/settings';
import './panellinks/module';
import './dashlinks/module';
// angular wrappers
import { react2AngularDirective } from 'app/core/utils/react2angular';
import DashboardPermissions from './permissions/DashboardPermissions';
react2AngularDirective('dashboardPermissions', DashboardPermissions, ['dashboardId', 'folder']);
import coreModule from 'app/core/core_module';
import { FolderDashboardsCtrl } from './folder_dashboards_ctrl';
import { DashboardImportCtrl } from './dashboard_import_ctrl';
import { CreateFolderCtrl } from './create_folder_ctrl';
coreModule.controller('FolderDashboardsCtrl', FolderDashboardsCtrl);
coreModule.controller('DashboardImportCtrl', DashboardImportCtrl);
coreModule.controller('CreateFolderCtrl', CreateFolderCtrl);

View File

@ -0,0 +1 @@
export { AdHocFiltersCtrl } from './AdHocFiltersCtrl';

View File

@ -2,7 +2,7 @@ import angular from 'angular';
import { saveAs } from 'file-saver';
import coreModule from 'app/core/core_module';
import { DashboardExporter } from './exporter';
import { DashboardExporter } from './DashboardExporter';
export class DashExportCtrl {
dash: any;
@ -66,7 +66,7 @@ export class DashExportCtrl {
export function dashExportDirective() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/export/export_modal.html',
templateUrl: 'public/app/features/dashboard/components/DashExportModal/template.html',
controller: DashExportCtrl,
bindToController: true,
controllerAs: 'ctrl',

View File

@ -6,8 +6,8 @@ jest.mock('app/core/store', () => {
import _ from 'lodash';
import config from 'app/core/config';
import { DashboardExporter } from '../export/exporter';
import { DashboardModel } from '../dashboard_model';
import { DashboardExporter } from './DashboardExporter';
import { DashboardModel } from '../../dashboard_model';
describe('given dashboard with repeated panels', () => {
let dash, exported;

View File

@ -1,6 +1,6 @@
import config from 'app/core/config';
import _ from 'lodash';
import { DashboardModel } from '../dashboard_model';
import { DashboardModel } from '../../dashboard_model';
export class DashboardExporter {
constructor(private datasourceSrv) {}

View File

@ -0,0 +1,2 @@
export { DashboardExporter } from './DashboardExporter';
export { DashExportCtrl } from './DashExportCtrl';

View File

@ -1,6 +1,6 @@
import angular from 'angular';
import _ from 'lodash';
import { iconMap } from './editor';
import { iconMap } from './DashLinksEditorCtrl';
function dashLinksContainer() {
return {

View File

@ -11,7 +11,7 @@ export let iconMap = {
cloud: 'fa-cloud',
};
export class DashLinkEditorCtrl {
export class DashLinksEditorCtrl {
dashboard: any;
iconMap: any;
mode: any;
@ -65,8 +65,8 @@ export class DashLinkEditorCtrl {
function dashLinksEditor() {
return {
restrict: 'E',
controller: DashLinkEditorCtrl,
templateUrl: 'public/app/features/dashboard/dashlinks/editor.html',
controller: DashLinksEditorCtrl,
templateUrl: 'public/app/features/dashboard/components/DashLinks/editor.html',
bindToController: true,
controllerAs: 'ctrl',
scope: {

View File

@ -0,0 +1,2 @@
export { DashLinksContainerCtrl } from './DashLinksContainerCtrl';
export { DashLinksEditorCtrl } from './DashLinksEditorCtrl';

View File

@ -1,7 +1,7 @@
import moment from 'moment';
import angular from 'angular';
import { appEvents, NavModel } from 'app/core/core';
import { DashboardModel } from '../dashboard_model';
import { DashboardModel } from '../../dashboard_model';
export class DashNavCtrl {
dashboard: DashboardModel;
@ -60,7 +60,7 @@ export class DashNavCtrl {
modalScope.dashboard = this.dashboard;
appEvents.emit('show-modal', {
src: 'public/app/features/dashboard/partials/shareModal.html',
src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: modalScope,
});
}
@ -107,7 +107,7 @@ export class DashNavCtrl {
export function dashNavDirective() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/dashnav/dashnav.html',
templateUrl: 'public/app/features/dashboard/components/DashNav/template.html',
controller: DashNavCtrl,
bindToController: true,
controllerAs: 'ctrl',

View File

@ -0,0 +1 @@
export { DashNavCtrl } from './DashNavCtrl';

View File

@ -8,11 +8,11 @@ import {
addDashboardPermission,
removeDashboardPermission,
updateDashboardPermission,
} from '../state/actions';
} from '../../state/actions';
import PermissionList from 'app/core/components/PermissionList/PermissionList';
import AddPermission from 'app/core/components/PermissionList/AddPermission';
import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo';
import { connectWithStore } from '../../../core/utils/connectWithReduxStore';
import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
export interface Props {
dashboardId: number;

View File

@ -1,5 +1,5 @@
import { coreModule, appEvents, contextSrv } from 'app/core/core';
import { DashboardModel } from '../dashboard_model';
import { DashboardModel } from '../../dashboard_model';
import $ from 'jquery';
import _ from 'lodash';
import angular from 'angular';
@ -230,7 +230,7 @@ export class SettingsCtrl {
export function dashboardSettings() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/settings/settings.html',
templateUrl: 'public/app/features/dashboard/components/DashboardSettings/template.html',
controller: SettingsCtrl,
bindToController: true,
controllerAs: 'ctrl',

View File

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

View File

@ -51,7 +51,8 @@
on-change="ctrl.onFolderChange($folder)"
enable-create-new="true"
is-valid-selection="true"
label-class="width-7">
label-class="width-7"
dashboard-id="ctrl.dashboard.id">
</folder-picker>
<gf-form-switch class="gf-form" label="Editable" tooltip="Uncheck, then save and reload to disable all dashboard editing" checked="ctrl.dashboard.editable" label-class="width-7">
</gf-form-switch>

View File

@ -31,7 +31,7 @@ export class ExportDataModalCtrl {
export function exportDataModal() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/export_data/export_data_modal.html',
templateUrl: 'public/app/features/dashboard/components/ExportDataModal/template.html',
controller: ExportDataModalCtrl,
controllerAs: 'ctrl',
scope: {

View File

@ -0,0 +1 @@
export { ExportDataModalCtrl } from './ExportDataModalCtrl';

View File

@ -21,6 +21,7 @@ export class FolderPickerCtrl {
hasValidationError: boolean;
validationError: any;
isEditor: boolean;
dashboardId?: number;
/** @ngInject */
constructor(private backendSrv, private validationSrv, private contextSrv) {
@ -144,7 +145,13 @@ export class FolderPickerCtrl {
if (this.isEditor) {
folder = rootFolder;
} else {
folder = result.length > 0 ? result[0] : resetFolder;
// We shouldn't assign a random folder without the user actively choosing it on a persisted dashboard
const isPersistedDashBoard = this.dashboardId ? true : false;
if (isPersistedDashBoard) {
folder = resetFolder;
} else {
folder = result.length > 0 ? result[0] : resetFolder;
}
}
}
@ -161,7 +168,7 @@ export class FolderPickerCtrl {
export function folderPicker() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/folder_picker/folder_picker.html',
templateUrl: 'public/app/features/dashboard/components/FolderPicker/template.html',
controller: FolderPickerCtrl,
bindToController: true,
controllerAs: 'ctrl',
@ -176,6 +183,7 @@ export function folderPicker() {
exitFolderCreation: '&',
enableCreateNew: '@',
enableReset: '@',
dashboardId: '<?',
},
};
}

View File

@ -0,0 +1 @@
export { FolderPickerCtrl } from './FolderPickerCtrl';

View File

@ -1,4 +1,4 @@
import { SaveDashboardAsModalCtrl } from '../save_as_modal';
import { SaveDashboardAsModalCtrl } from './SaveDashboardAsModalCtrl';
import { describe, it, expect } from 'test/lib/common';
describe('saving dashboard as', () => {

View File

@ -25,7 +25,8 @@ const template = `
enter-folder-creation="ctrl.onEnterFolderCreation()"
exit-folder-creation="ctrl.onExitFolderCreation()"
enable-create-new="true"
label-class="width-7">
label-class="width-7"
dashboard-id="ctrl.clone.id">
</folder-picker>
</div>
</div>

View File

@ -1,4 +1,4 @@
import { SaveDashboardModalCtrl } from '../save_modal';
import { SaveDashboardModalCtrl } from './SaveDashboardModalCtrl';
const setup = (timeChanged, variableValuesChanged, cb) => {
const dash = {

View File

@ -1,4 +1,4 @@
import { SaveProvisionedDashboardModalCtrl } from '../save_provisioned_modal';
import { SaveProvisionedDashboardModalCtrl } from './SaveProvisionedDashboardModalCtrl';
describe('SaveProvisionedDashboardModalCtrl', () => {
const json = {

View File

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

View File

@ -1,7 +1,6 @@
import '../shareModalCtrl';
import { ShareModalCtrl } from '../shareModalCtrl';
import config from 'app/core/config';
import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv';
import { ShareModalCtrl } from './ShareModalCtrl';
describe('ShareModalCtrl', () => {
const ctx = {

View File

@ -0,0 +1,2 @@
export { ShareModalCtrl } from './ShareModalCtrl';
export { ShareSnapshotCtrl } from './ShareSnapshotCtrl';

View File

@ -1,7 +1,7 @@
import angular from 'angular';
import _ from 'lodash';
export class SubmenuCtrl {
export class SubMenuCtrl {
annotations: any;
variables: any;
dashboard: any;
@ -29,8 +29,8 @@ export class SubmenuCtrl {
export function submenuDirective() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/submenu/submenu.html',
controller: SubmenuCtrl,
templateUrl: 'public/app/features/dashboard/components/SubMenu/template.html',
controller: SubMenuCtrl,
bindToController: true,
controllerAs: 'ctrl',
scope: {

View File

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

View File

@ -159,7 +159,7 @@ export class TimePickerCtrl {
export function settingsDirective() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/timepicker/settings.html',
templateUrl: 'public/app/features/dashboard/components/TimePicker/settings.html',
controller: TimePickerCtrl,
bindToController: true,
controllerAs: 'ctrl',
@ -172,7 +172,7 @@ export function settingsDirective() {
export function timePickerDirective() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/timepicker/timepicker.html',
templateUrl: 'public/app/features/dashboard/components/TimePicker/template.html',
controller: TimePickerCtrl,
bindToController: true,
controllerAs: 'ctrl',
@ -185,5 +185,5 @@ export function timePickerDirective() {
angular.module('grafana.directives').directive('gfTimePickerSettings', settingsDirective);
angular.module('grafana.directives').directive('gfTimePicker', timePickerDirective);
import { inputDateDirective } from './input_date';
import { inputDateDirective } from './validation';
angular.module('grafana.directives').directive('inputDatetime', inputDateDirective);

View File

@ -0,0 +1 @@
export { TimePickerCtrl } from './TimePickerCtrl';

View File

@ -0,0 +1 @@
export { UnsavedChangesModalCtrl } from './UnsavedChangesModalCtrl';

View File

@ -1,6 +1,6 @@
import _ from 'lodash';
import { HistoryListCtrl } from 'app/features/dashboard/history/history';
import { versions, compare, restore } from './history_mocks';
import { HistoryListCtrl } from './HistoryListCtrl';
import { versions, compare, restore } from './__mocks__/history';
import $q from 'q';
describe('HistoryListCtrl', () => {

View File

@ -1,12 +1,10 @@
import './history_srv';
import _ from 'lodash';
import angular from 'angular';
import moment from 'moment';
import locationUtil from 'app/core/utils/location_util';
import { DashboardModel } from '../dashboard_model';
import { HistoryListOpts, RevisionsModel, CalculateDiffOptions, HistorySrv } from './history_srv';
import { DashboardModel } from '../../dashboard_model';
import { HistoryListOpts, RevisionsModel, CalculateDiffOptions, HistorySrv } from './HistorySrv';
export class HistoryListCtrl {
appending: boolean;
@ -200,7 +198,7 @@ export class HistoryListCtrl {
export function dashboardHistoryDirective() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/history/history.html',
templateUrl: 'public/app/features/dashboard/components/VersionHistory/template.html',
controller: HistoryListCtrl,
bindToController: true,
controllerAs: 'ctrl',

View File

@ -1,7 +1,6 @@
import '../history/history_srv';
import { versions, restore } from './history_mocks';
import { HistorySrv } from '../history/history_srv';
import { DashboardModel } from '../dashboard_model';
import { versions, restore } from './__mocks__/history';
import { HistorySrv } from './HistorySrv';
import { DashboardModel } from '../../dashboard_model';
jest.mock('app/core/store');
describe('historySrv', () => {

View File

@ -1,6 +1,6 @@
import _ from 'lodash';
import coreModule from 'app/core/core_module';
import { DashboardModel } from '../dashboard_model';
import { DashboardModel } from '../../dashboard_model';
export interface HistoryListOpts {
limit: number;

View File

@ -0,0 +1,2 @@
export { HistoryListCtrl } from './HistoryListCtrl';
export { HistorySrv } from './HistorySrv';

View File

@ -22,7 +22,6 @@ export class DashboardCtrl {
private keybindingSrv,
private timeSrv,
private variableSrv,
private alertingSrv,
private dashboardSrv,
private unsavedChangesSrv,
private dashboardViewStateSrv,
@ -54,7 +53,6 @@ export class DashboardCtrl {
// init services
this.timeSrv.init(dashboard);
this.alertingSrv.init(dashboard, data.alerts);
this.annotationsSrv.init(dashboard);
// template values service needs to initialize completely before

View File

@ -1,25 +0,0 @@
import { FolderPageLoader } from './folder_page_loader';
export class FolderPermissionsCtrl {
navModel: any;
folderId: number;
uid: string;
dashboard: any;
meta: any;
/** @ngInject */
constructor(private backendSrv, navModelSrv, private $routeParams, $location) {
if (this.$routeParams.uid) {
this.uid = $routeParams.uid;
new FolderPageLoader(this.backendSrv).load(this, this.uid, 'manage-folder-permissions').then(folder => {
if ($location.path() !== folder.meta.url) {
$location.path(`${folder.meta.url}/permissions`).replace();
}
this.dashboard = folder.dashboard;
this.meta = folder.meta;
});
}
}
}

View File

@ -0,0 +1,35 @@
import './dashboard_ctrl';
import './time_srv';
import './repeat_option/repeat_option';
import './dashgrid/DashboardGridDirective';
import './dashgrid/RowOptions';
import './panellinks/module';
// Services
import './services/DashboardViewStateSrv';
import './services/UnsavedChangesSrv';
import './services/DashboardLoaderSrv';
import './services/DashboardSrv';
// Components
import './components/DashLinks';
import './components/DashExportModal';
import './components/DashNav';
import './components/ExportDataModal';
import './components/FolderPicker';
import './components/VersionHistory';
import './components/DashboardSettings';
import './components/SubMenu';
import './components/TimePicker';
import './components/UnsavedChangesModal';
import './components/SaveModals';
import './components/ShareModal';
import './components/AdHocFilters';
import DashboardPermissions from './components/DashboardPermissions/DashboardPermissions';
// angular wrappers
import { react2AngularDirective } from 'app/core/utils/react2angular';
react2AngularDirective('dashboardPermissions', DashboardPermissions, ['dashboardId', 'folder']);

View File

@ -166,6 +166,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
onDisableQuery = () => {
this.props.query.hide = !this.props.query.hide;
this.onExecuteQuery();
this.forceUpdate();
};

View File

@ -1,4 +1,4 @@
import { ChangeTracker } from 'app/features/dashboard/change_tracker';
import { ChangeTracker } from './ChangeTracker';
import { contextSrv } from 'app/core/services/context_srv';
import { DashboardModel } from '../dashboard_model';
import { PanelModel } from '../panel_model';

View File

@ -1,6 +1,6 @@
import angular from 'angular';
import _ from 'lodash';
import { DashboardModel } from './dashboard_model';
import { DashboardModel } from '../dashboard_model';
export class ChangeTracker {
current: any;

View File

@ -1,5 +1,5 @@
import coreModule from 'app/core/core_module';
import { DashboardModel } from './dashboard_model';
import { DashboardModel } from '../dashboard_model';
import locationUtil from 'app/core/utils/location_util';
export class DashboardSrv {

View File

@ -1,7 +1,5 @@
//import { describe, beforeEach, it, expect, angularMocks } from 'test/lib/common';
import 'app/features/dashboard/view_state_srv';
import config from 'app/core/config';
import { DashboardViewState } from '../view_state_srv';
import { DashboardViewStateSrv } from './DashboardViewStateSrv';
import { DashboardModel } from '../dashboard_model';
describe('when updating view state', () => {
@ -33,7 +31,7 @@ describe('when updating view state', () => {
location.search = jest.fn(() => {
return { fullscreen: true, edit: true, panelId: 1 };
});
viewState = new DashboardViewState($scope, location, {});
viewState = new DashboardViewStateSrv($scope, location, {});
});
it('should update querystring and view state', () => {
@ -55,7 +53,7 @@ describe('when updating view state', () => {
describe('to fullscreen false', () => {
beforeEach(() => {
viewState = new DashboardViewState($scope, location, {});
viewState = new DashboardViewStateSrv($scope, location, {});
});
it('should remove params from query string', () => {
viewState.update({ fullscreen: true, panelId: 1, edit: true });

View File

@ -2,11 +2,11 @@ import angular from 'angular';
import _ from 'lodash';
import config from 'app/core/config';
import appEvents from 'app/core/app_events';
import { DashboardModel } from './dashboard_model';
import { DashboardModel } from '../dashboard_model';
// represents the transient view state
// like fullscreen panel & edit
export class DashboardViewState {
export class DashboardViewStateSrv {
state: any;
panelScopes: any;
$scope: any;
@ -168,7 +168,7 @@ export class DashboardViewState {
export function dashboardViewStateSrv($location, $timeout) {
return {
create: $scope => {
return new DashboardViewState($scope, $location, $timeout);
return new DashboardViewStateSrv($scope, $location, $timeout);
},
};
}

View File

@ -1,5 +1,5 @@
import angular from 'angular';
import { ChangeTracker } from './change_tracker';
import { ChangeTracker } from './ChangeTracker';
/** @ngInject */
export function unsavedChangesSrv(this: any, $rootScope, $q, $location, $timeout, contextSrv, dashboardSrv, $window) {

View File

@ -80,7 +80,7 @@ export const editPanelJson = (dashboard: DashboardModel, panel: PanelModel) => {
export const sharePanel = (dashboard: DashboardModel, panel: PanelModel) => {
appEvents.emit('show-modal', {
src: 'public/app/features/dashboard/partials/shareModal.html',
src: 'public/app/features/dashboard/components/ShareModal/template.html',
model: {
dashboard: dashboard,
panel: panel,

View File

@ -5,7 +5,7 @@ import { connect } from 'react-redux';
import { ExploreId, ExploreItemState } from 'app/types/explore';
import { StoreState } from 'app/types';
import { toggleGraph } from './state/actions';
import { toggleTable } from './state/actions';
import Table from './Table';
import Panel from './Panel';
import TableModel from 'app/core/table_model';
@ -16,12 +16,12 @@ interface TableContainerProps {
onClickCell: (key: string, value: string) => void;
showingTable: boolean;
tableResult?: TableModel;
toggleGraph: typeof toggleGraph;
toggleTable: typeof toggleTable;
}
export class TableContainer extends PureComponent<TableContainerProps> {
onClickTableButton = () => {
this.props.toggleGraph(this.props.exploreId);
this.props.toggleTable(this.props.exploreId);
};
render() {
@ -43,7 +43,7 @@ function mapStateToProps(state: StoreState, { exploreId }) {
}
const mapDispatchToProps = {
toggleGraph,
toggleTable,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(TableContainer));

View File

@ -0,0 +1,42 @@
import { Action, ActionTypes } from './actionTypes';
import { itemReducer, makeExploreItemState } from './reducers';
import { ExploreId } from 'app/types/explore';
describe('Explore item reducer', () => {
describe('scanning', () => {
test('should start scanning', () => {
let state = makeExploreItemState();
const action: Action = {
type: ActionTypes.ScanStart,
payload: {
exploreId: ExploreId.left,
scanner: jest.fn(),
},
};
state = itemReducer(state, action);
expect(state.scanning).toBeTruthy();
expect(state.scanner).toBe(action.payload.scanner);
});
test('should stop scanning', () => {
let state = makeExploreItemState();
const start: Action = {
type: ActionTypes.ScanStart,
payload: {
exploreId: ExploreId.left,
scanner: jest.fn(),
},
};
state = itemReducer(state, start);
expect(state.scanning).toBeTruthy();
const action: Action = {
type: ActionTypes.ScanStop,
payload: {
exploreId: ExploreId.left,
},
};
state = itemReducer(state, action);
expect(state.scanning).toBeFalsy();
expect(state.scanner).toBeUndefined();
});
});
});

View File

@ -20,7 +20,7 @@ const DEFAULT_GRAPH_INTERVAL = 15 * 1000;
/**
* Returns a fresh Explore area state
*/
const makeExploreItemState = (): ExploreItemState => ({
export const makeExploreItemState = (): ExploreItemState => ({
StartPage: undefined,
containerWidth: 0,
datasourceInstance: null,
@ -48,7 +48,7 @@ const makeExploreItemState = (): ExploreItemState => ({
/**
* Global Explore state that handles multiple Explore areas and the split state
*/
const initialExploreState: ExploreState = {
export const initialExploreState: ExploreState = {
split: null,
left: makeExploreItemState(),
right: makeExploreItemState(),
@ -57,7 +57,7 @@ const initialExploreState: ExploreState = {
/**
* Reducer for an Explore area, to be used by the global Explore reducer.
*/
const itemReducer = (state, action: Action): ExploreItemState => {
export const itemReducer = (state, action: Action): ExploreItemState => {
switch (action.type) {
case ActionTypes.AddQueryRow: {
const { initialQueries, modifiedQueries, queryTransactions } = state;
@ -360,13 +360,19 @@ const itemReducer = (state, action: Action): ExploreItemState => {
}
case ActionTypes.ScanStart: {
return { ...state, scanning: true };
return { ...state, scanning: true, scanner: action.payload.scanner };
}
case ActionTypes.ScanStop: {
const { queryTransactions } = state;
const nextQueryTransactions = queryTransactions.filter(qt => qt.scanning && !qt.done);
return { ...state, queryTransactions: nextQueryTransactions, scanning: false, scanRange: undefined };
return {
...state,
queryTransactions: nextQueryTransactions,
scanning: false,
scanRange: undefined,
scanner: undefined,
};
}
case ActionTypes.SetQueries: {

View File

@ -1,5 +1,5 @@
import { DashboardImportCtrl } from '../dashboard_import_ctrl';
import config from '../../../core/config';
import { DashboardImportCtrl } from './DashboardImportCtrl';
import config from 'app/core/config';
describe('DashboardImportCtrl', () => {
const ctx: any = {};

View File

@ -1,4 +1,4 @@
import { FolderPageLoader } from './folder_page_loader';
import { FolderPageLoader } from './services/FolderPageLoader';
import locationUtil from 'app/core/utils/location_util';
export class FolderDashboardsCtrl {

View File

@ -46,7 +46,7 @@ export class MoveToFolderCtrl {
export function moveToFolderModal() {
return {
restrict: 'E',
templateUrl: 'public/app/features/dashboard/move_to_folder_modal/move_to_folder.html',
templateUrl: 'public/app/features/manage-dashboards/components/MoveToFolderModal/template.html',
controller: MoveToFolderCtrl,
bindToController: true,
controllerAs: 'ctrl',

View File

@ -0,0 +1 @@
export { MoveToFolderCtrl } from './MoveToFolderCtrl';

View File

@ -0,0 +1 @@
export { uploadDashboardDirective } from './uploadDashboardDirective';

View File

@ -11,7 +11,7 @@ const template = `
`;
/** @ngInject */
function uploadDashboardDirective(timer, $location) {
export function uploadDashboardDirective(timer, $location) {
return {
restrict: 'E',
template: template,

View File

@ -1,7 +1,21 @@
import coreModule from 'app/core/core_module';
// Services
export { ValidationSrv } from './services/ValidationSrv';
// Components
export * from './components/MoveToFolderModal';
export * from './components/UploadDashboard';
// Controllers
import { DashboardListCtrl } from './DashboardListCtrl';
import { SnapshotListCtrl } from './SnapshotListCtrl';
import { FolderDashboardsCtrl } from './FolderDashboardsCtrl';
import { DashboardImportCtrl } from './DashboardImportCtrl';
import { CreateFolderCtrl } from './CreateFolderCtrl';
import coreModule from 'app/core/core_module';
coreModule.controller('DashboardListCtrl', DashboardListCtrl);
coreModule.controller('SnapshotListCtrl', SnapshotListCtrl);
coreModule.controller('FolderDashboardsCtrl', FolderDashboardsCtrl);
coreModule.controller('DashboardImportCtrl', DashboardImportCtrl);
coreModule.controller('CreateFolderCtrl', CreateFolderCtrl);

View File

@ -4,12 +4,13 @@ import appEvents from 'app/core/app_events';
import _ from 'lodash';
import { toUrlParams } from 'app/core/utils/url';
class PlaylistSrv {
export class PlaylistSrv {
private cancelPromise: any;
private dashboards: any;
private dashboards: Array<{ uri: string }>;
private index: number;
private interval: any;
private interval: number;
private startUrl: string;
private numberOfLoops = 0;
isPlaying: boolean;
/** @ngInject */
@ -20,8 +21,15 @@ class PlaylistSrv {
const playedAllDashboards = this.index > this.dashboards.length - 1;
if (playedAllDashboards) {
window.location.href = this.startUrl;
return;
this.numberOfLoops++;
// This does full reload of the playlist to keep memory in check due to existing leaks but at the same time
// we do not want page to flicker after each full loop.
if (this.numberOfLoops >= 3) {
window.location.href = this.startUrl;
return;
}
this.index = 0;
}
const dash = this.dashboards[this.index];
@ -46,8 +54,8 @@ class PlaylistSrv {
this.index = 0;
this.isPlaying = true;
this.backendSrv.get(`/api/playlists/${playlistId}`).then(playlist => {
this.backendSrv.get(`/api/playlists/${playlistId}/dashboards`).then(dashboards => {
return this.backendSrv.get(`/api/playlists/${playlistId}`).then(playlist => {
return this.backendSrv.get(`/api/playlists/${playlistId}/dashboards`).then(dashboards => {
this.dashboards = dashboards;
this.interval = kbn.interval_to_ms(playlist.interval);
this.next();

View File

@ -0,0 +1,103 @@
import { PlaylistSrv } from '../playlist_srv';
const dashboards = [{ uri: 'dash1' }, { uri: 'dash2' }];
const createPlaylistSrv = (): [PlaylistSrv, { url: jest.MockInstance<any> }] => {
const mockBackendSrv = {
get: jest.fn(url => {
switch (url) {
case '/api/playlists/1':
return Promise.resolve({ interval: '1s' });
case '/api/playlists/1/dashboards':
return Promise.resolve(dashboards);
default:
throw new Error(`Unexpected url=${url}`);
}
}),
};
const mockLocation = {
url: jest.fn(),
search: () => ({}),
};
const mockTimeout = jest.fn();
(mockTimeout as any).cancel = jest.fn();
return [new PlaylistSrv(mockLocation, mockTimeout, mockBackendSrv), mockLocation];
};
const mockWindowLocation = (): [jest.MockInstance<any>, () => void] => {
const oldLocation = window.location;
const hrefMock = jest.fn();
// JSDom defines window in a way that you cannot tamper with location so this seems to be the only way to change it.
// https://github.com/facebook/jest/issues/5124#issuecomment-446659510
delete window.location;
window.location = {} as any;
// Only mocking href as that is all this test needs, but otherwise there is lots of things missing, so keep that
// in mind if this is reused.
Object.defineProperty(window.location, 'href', {
set: hrefMock,
get: hrefMock,
});
const unmock = () => {
window.location = oldLocation;
};
return [hrefMock, unmock];
};
describe('PlaylistSrv', () => {
let srv: PlaylistSrv;
let mockLocationService: { url: jest.MockInstance<any> };
let hrefMock: jest.MockInstance<any>;
let unmockLocation: () => void;
const initialUrl = 'http://localhost/playlist';
beforeEach(() => {
[srv, mockLocationService] = createPlaylistSrv();
[hrefMock, unmockLocation] = mockWindowLocation();
// This will be cached in the srv when start() is called
hrefMock.mockReturnValue(initialUrl);
});
afterEach(() => {
unmockLocation();
});
it('runs all dashboards in cycle and reloads page after 3 cycles', async () => {
await srv.start(1);
for (let i = 0; i < 6; i++) {
expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
srv.next();
}
expect(hrefMock).toHaveBeenCalledTimes(2);
expect(hrefMock).toHaveBeenLastCalledWith(initialUrl);
});
it('keeps the refresh counter value after restarting', async () => {
await srv.start(1);
// 1 complete loop
for (let i = 0; i < 3; i++) {
expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
srv.next();
}
srv.stop();
await srv.start(1);
// Another 2 loops
for (let i = 0; i < 4; i++) {
expect(mockLocationService.url).toHaveBeenLastCalledWith(`dashboard/${dashboards[i % 2].uri}?`);
srv.next();
}
expect(hrefMock).toHaveBeenCalledTimes(3);
expect(hrefMock).toHaveBeenLastCalledWith(initialUrl);
});
});

View File

@ -275,6 +275,11 @@ describe('templateSrv', () => {
expect(result).toBe('test,test2');
});
it('multi value and percentencode format should render percent-encoded string', () => {
const result = _templateSrv.formatValue(['foo()bar BAZ', 'test2'], 'percentencode');
expect(result).toBe('%7Bfoo%28%29bar%20BAZ%2Ctest2%7D');
});
it('slash should be properly escaped in regex format', () => {
const result = _templateSrv.formatValue('Gi3/14', 'regex');
expect(result).toBe('Gi3\\/14');

View File

@ -77,6 +77,15 @@ export class TemplateSrv {
return '(' + quotedValues.join(' OR ') + ')';
}
// encode string according to RFC 3986; in contrast to encodeURIComponent()
// also the sub-delims "!", "'", "(", ")" and "*" are encoded;
// unicode handling uses UTF-8 as in ECMA-262.
encodeURIComponentStrict(str) {
return encodeURIComponent(str).replace(/[!'()*]/g, (c) => {
return '%' + c.charCodeAt(0).toString(16).toUpperCase();
});
}
formatValue(value, format, variable) {
// for some scopedVars there is no variable
variable = variable || {};
@ -118,6 +127,13 @@ export class TemplateSrv {
}
return value;
}
case 'percentencode': {
// like glob, but url escaped
if (_.isArray(value)) {
return this.encodeURIComponentStrict('{' + value.join(',') + '}');
}
return this.encodeURIComponentStrict(value);
}
default: {
if (_.isArray(value)) {
return '{' + value.join(',') + '}';

View File

@ -26,7 +26,7 @@ export default (props: any) => (
<div>
<h2>Loki Cheat Sheet</h2>
{CHEAT_SHEET_ITEMS.map(item => (
<div className="cheat-sheet-item" key={item.expression}>
<div className="cheat-sheet-item" key={item.title}>
<div className="cheat-sheet-item__title">{item.title}</div>
{item.expression && (
<div

View File

@ -10,23 +10,6 @@
"id": null,
"links": [],
"panels": [
{
"content": "<div class=\"text-center dashboard-header\">\n <span>Home Dashboard</span>\n</div>",
"editable": true,
"id": 1,
"links": [],
"mode": "html",
"style": {},
"title": "",
"transparent": true,
"type": "text",
"gridPos": {
"w": 24,
"h": 3,
"x": 0,
"y": 0
}
},
{
"folderId": 0,
"headings": true,
@ -45,7 +28,7 @@
"w": 12,
"h": 17,
"x": 0,
"y": 6
"y": 1
}
},
{
@ -60,7 +43,7 @@
"w": 12,
"h": 17,
"x": 12,
"y": 6
"y": 1
}
}
],

62
style_guides/frontend.md Normal file
View File

@ -0,0 +1,62 @@
# Frontend Style Guide
Generally we follow the Airbnb [React Style Guide](https://github.com/airbnb/javascript/tree/master/react).
## Table of Contents
1. [Basic Rules](#basic-rules)
1. [File & Component Organization](#Organization)
1. [Naming](#naming)
1. [Declaration](#declaration)
1. [Props](#props)
1. [Refs](#refs)
1. [Methods](#methods)
1. [Ordering](#ordering)
## Basic rules
* Try to keep files small and focused and break large components up into sub components.
## Organization
* Components and types that needs to be used by external plugins needs to go into @grafana/ui
* Components should get their own folder under features/xxx/components
* Sub components can live in that component folders, so not small component needs their own folder
* Place test next to their component file (same dir)
* Mocks in __mocks__ dir
* Test utils in __tests__ dir
* Component sass should live in the same folder as component code
* State logic & domain models should live in features/xxx/state
* Containers (pages) can live in feature root features/xxx
* up for debate?
## Props
* Name callback props & handlers with a "on" prefix.
```tsx
// good
onChange = () => {
};
render() {
return (
<MyComponent onChange={this.onChange} />
);
}
// bad
handleChange = () => {
};
render() {
return (
<MyComponent changed={this.handleChange} />
);
}
```