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) * **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) * **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) * **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) * **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) * **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) * **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) ### Short term (1-2 months)
- PRs & Bugs - PRs & Bugs
- Multi-Stat panel - React Panel Support
- React Query Editor Support
- Metrics & Log Explore UI - 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) ### Mid term (2-4 months)
- React Panels - Drilldown links
- Change visualization (panel type) on the fly. - Dashboards as code workflows
- Templating Query Editor UI Plugin hook - React migration
- Backend plugins - New panels
### Long term (4 - 8 months) ### Long term (4 - 8 months)
- Alerting improvements (silence, per series tracking, etc) - Alerting improvements (silence, per series tracking, etc)
- Progress on React migration
### In a distant future far far away ### In a distant future far far away
- Meta queries - Meta queries

View File

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

View File

@ -391,6 +391,12 @@ value is `true`.
If you want to track Grafana usage via Google analytics specify *your* Universal If you want to track Grafana usage via Google analytics specify *your* Universal
Analytics ID here. By default this feature is disabled. 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 /> <hr />
## [dashboards] ## [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 `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. `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. `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). 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, "id": 123123,
"gridPos": map[string]interface{}{ "gridPos": map[string]interface{}{
"x": 0, "x": 0,
"y": 3, "y": 0,
"w": 24, "w": 24,
"h": 4, "h": 4,
}, },

View File

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

View File

@ -44,9 +44,25 @@ export function findMatchesInText(haystack: string, needle: string): TextMatch[]
return matches; 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 { export function sanitize (unsanitizedString: string): string {
try { try {
return xss(unsanitizedString); return sanitizeXSS.process(unsanitizedString);
} catch (error) { } catch (error) {
console.log('String could not be sanitized', unsanitizedString); console.log('String could not be sanitized', unsanitizedString);
return unsanitizedString; return unsanitizedString;

View File

@ -1,7 +1,7 @@
import './annotations/all'; import './annotations/all';
import './templating/all'; import './templating/all';
import './plugins/all'; import './plugins/all';
import './dashboard/all'; import './dashboard';
import './playlist/all'; import './playlist/all';
import './panel/all'; import './panel/all';
import './org/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 { saveAs } from 'file-saver';
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
import { DashboardExporter } from './exporter'; import { DashboardExporter } from './DashboardExporter';
export class DashExportCtrl { export class DashExportCtrl {
dash: any; dash: any;
@ -66,7 +66,7 @@ export class DashExportCtrl {
export function dashExportDirective() { export function dashExportDirective() {
return { return {
restrict: 'E', restrict: 'E',
templateUrl: 'public/app/features/dashboard/export/export_modal.html', templateUrl: 'public/app/features/dashboard/components/DashExportModal/template.html',
controller: DashExportCtrl, controller: DashExportCtrl,
bindToController: true, bindToController: true,
controllerAs: 'ctrl', controllerAs: 'ctrl',

View File

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

View File

@ -1,6 +1,6 @@
import config from 'app/core/config'; import config from 'app/core/config';
import _ from 'lodash'; import _ from 'lodash';
import { DashboardModel } from '../dashboard_model'; import { DashboardModel } from '../../dashboard_model';
export class DashboardExporter { export class DashboardExporter {
constructor(private datasourceSrv) {} 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 angular from 'angular';
import _ from 'lodash'; import _ from 'lodash';
import { iconMap } from './editor'; import { iconMap } from './DashLinksEditorCtrl';
function dashLinksContainer() { function dashLinksContainer() {
return { return {

View File

@ -11,7 +11,7 @@ export let iconMap = {
cloud: 'fa-cloud', cloud: 'fa-cloud',
}; };
export class DashLinkEditorCtrl { export class DashLinksEditorCtrl {
dashboard: any; dashboard: any;
iconMap: any; iconMap: any;
mode: any; mode: any;
@ -65,8 +65,8 @@ export class DashLinkEditorCtrl {
function dashLinksEditor() { function dashLinksEditor() {
return { return {
restrict: 'E', restrict: 'E',
controller: DashLinkEditorCtrl, controller: DashLinksEditorCtrl,
templateUrl: 'public/app/features/dashboard/dashlinks/editor.html', templateUrl: 'public/app/features/dashboard/components/DashLinks/editor.html',
bindToController: true, bindToController: true,
controllerAs: 'ctrl', controllerAs: 'ctrl',
scope: { 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 moment from 'moment';
import angular from 'angular'; import angular from 'angular';
import { appEvents, NavModel } from 'app/core/core'; import { appEvents, NavModel } from 'app/core/core';
import { DashboardModel } from '../dashboard_model'; import { DashboardModel } from '../../dashboard_model';
export class DashNavCtrl { export class DashNavCtrl {
dashboard: DashboardModel; dashboard: DashboardModel;
@ -60,7 +60,7 @@ export class DashNavCtrl {
modalScope.dashboard = this.dashboard; modalScope.dashboard = this.dashboard;
appEvents.emit('show-modal', { appEvents.emit('show-modal', {
src: 'public/app/features/dashboard/partials/shareModal.html', src: 'public/app/features/dashboard/components/ShareModal/template.html',
scope: modalScope, scope: modalScope,
}); });
} }
@ -107,7 +107,7 @@ export class DashNavCtrl {
export function dashNavDirective() { export function dashNavDirective() {
return { return {
restrict: 'E', restrict: 'E',
templateUrl: 'public/app/features/dashboard/dashnav/dashnav.html', templateUrl: 'public/app/features/dashboard/components/DashNav/template.html',
controller: DashNavCtrl, controller: DashNavCtrl,
bindToController: true, bindToController: true,
controllerAs: 'ctrl', controllerAs: 'ctrl',

View File

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

View File

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

View File

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

View File

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

View File

@ -51,7 +51,8 @@
on-change="ctrl.onFolderChange($folder)" on-change="ctrl.onFolderChange($folder)"
enable-create-new="true" enable-create-new="true"
is-valid-selection="true" is-valid-selection="true"
label-class="width-7"> label-class="width-7"
dashboard-id="ctrl.dashboard.id">
</folder-picker> </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 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> </gf-form-switch>

View File

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

View File

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

View File

@ -21,6 +21,7 @@ export class FolderPickerCtrl {
hasValidationError: boolean; hasValidationError: boolean;
validationError: any; validationError: any;
isEditor: boolean; isEditor: boolean;
dashboardId?: number;
/** @ngInject */ /** @ngInject */
constructor(private backendSrv, private validationSrv, private contextSrv) { constructor(private backendSrv, private validationSrv, private contextSrv) {
@ -144,7 +145,13 @@ export class FolderPickerCtrl {
if (this.isEditor) { if (this.isEditor) {
folder = rootFolder; folder = rootFolder;
} else { } 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() { export function folderPicker() {
return { return {
restrict: 'E', restrict: 'E',
templateUrl: 'public/app/features/dashboard/folder_picker/folder_picker.html', templateUrl: 'public/app/features/dashboard/components/FolderPicker/template.html',
controller: FolderPickerCtrl, controller: FolderPickerCtrl,
bindToController: true, bindToController: true,
controllerAs: 'ctrl', controllerAs: 'ctrl',
@ -176,6 +183,7 @@ export function folderPicker() {
exitFolderCreation: '&', exitFolderCreation: '&',
enableCreateNew: '@', enableCreateNew: '@',
enableReset: '@', 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'; import { describe, it, expect } from 'test/lib/common';
describe('saving dashboard as', () => { describe('saving dashboard as', () => {

View File

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

View File

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

View File

@ -1,4 +1,4 @@
import { SaveProvisionedDashboardModalCtrl } from '../save_provisioned_modal'; import { SaveProvisionedDashboardModalCtrl } from './SaveProvisionedDashboardModalCtrl';
describe('SaveProvisionedDashboardModalCtrl', () => { describe('SaveProvisionedDashboardModalCtrl', () => {
const json = { 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 config from 'app/core/config';
import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv'; import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv';
import { ShareModalCtrl } from './ShareModalCtrl';
describe('ShareModalCtrl', () => { describe('ShareModalCtrl', () => {
const ctx = { 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 angular from 'angular';
import _ from 'lodash'; import _ from 'lodash';
export class SubmenuCtrl { export class SubMenuCtrl {
annotations: any; annotations: any;
variables: any; variables: any;
dashboard: any; dashboard: any;
@ -29,8 +29,8 @@ export class SubmenuCtrl {
export function submenuDirective() { export function submenuDirective() {
return { return {
restrict: 'E', restrict: 'E',
templateUrl: 'public/app/features/dashboard/submenu/submenu.html', templateUrl: 'public/app/features/dashboard/components/SubMenu/template.html',
controller: SubmenuCtrl, controller: SubMenuCtrl,
bindToController: true, bindToController: true,
controllerAs: 'ctrl', controllerAs: 'ctrl',
scope: { scope: {

View File

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

View File

@ -159,7 +159,7 @@ export class TimePickerCtrl {
export function settingsDirective() { export function settingsDirective() {
return { return {
restrict: 'E', restrict: 'E',
templateUrl: 'public/app/features/dashboard/timepicker/settings.html', templateUrl: 'public/app/features/dashboard/components/TimePicker/settings.html',
controller: TimePickerCtrl, controller: TimePickerCtrl,
bindToController: true, bindToController: true,
controllerAs: 'ctrl', controllerAs: 'ctrl',
@ -172,7 +172,7 @@ export function settingsDirective() {
export function timePickerDirective() { export function timePickerDirective() {
return { return {
restrict: 'E', restrict: 'E',
templateUrl: 'public/app/features/dashboard/timepicker/timepicker.html', templateUrl: 'public/app/features/dashboard/components/TimePicker/template.html',
controller: TimePickerCtrl, controller: TimePickerCtrl,
bindToController: true, bindToController: true,
controllerAs: 'ctrl', controllerAs: 'ctrl',
@ -185,5 +185,5 @@ export function timePickerDirective() {
angular.module('grafana.directives').directive('gfTimePickerSettings', settingsDirective); angular.module('grafana.directives').directive('gfTimePickerSettings', settingsDirective);
angular.module('grafana.directives').directive('gfTimePicker', timePickerDirective); angular.module('grafana.directives').directive('gfTimePicker', timePickerDirective);
import { inputDateDirective } from './input_date'; import { inputDateDirective } from './validation';
angular.module('grafana.directives').directive('inputDatetime', inputDateDirective); 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 _ from 'lodash';
import { HistoryListCtrl } from 'app/features/dashboard/history/history'; import { HistoryListCtrl } from './HistoryListCtrl';
import { versions, compare, restore } from './history_mocks'; import { versions, compare, restore } from './__mocks__/history';
import $q from 'q'; import $q from 'q';
describe('HistoryListCtrl', () => { describe('HistoryListCtrl', () => {

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import _ from 'lodash'; import _ from 'lodash';
import coreModule from 'app/core/core_module'; import coreModule from 'app/core/core_module';
import { DashboardModel } from '../dashboard_model'; import { DashboardModel } from '../../dashboard_model';
export interface HistoryListOpts { export interface HistoryListOpts {
limit: number; 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 keybindingSrv,
private timeSrv, private timeSrv,
private variableSrv, private variableSrv,
private alertingSrv,
private dashboardSrv, private dashboardSrv,
private unsavedChangesSrv, private unsavedChangesSrv,
private dashboardViewStateSrv, private dashboardViewStateSrv,
@ -54,7 +53,6 @@ export class DashboardCtrl {
// init services // init services
this.timeSrv.init(dashboard); this.timeSrv.init(dashboard);
this.alertingSrv.init(dashboard, data.alerts);
this.annotationsSrv.init(dashboard); this.annotationsSrv.init(dashboard);
// template values service needs to initialize completely before // 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 = () => { onDisableQuery = () => {
this.props.query.hide = !this.props.query.hide; this.props.query.hide = !this.props.query.hide;
this.onExecuteQuery();
this.forceUpdate(); 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 { contextSrv } from 'app/core/services/context_srv';
import { DashboardModel } from '../dashboard_model'; import { DashboardModel } from '../dashboard_model';
import { PanelModel } from '../panel_model'; import { PanelModel } from '../panel_model';

View File

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

View File

@ -1,5 +1,5 @@
import coreModule from 'app/core/core_module'; 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'; import locationUtil from 'app/core/utils/location_util';
export class DashboardSrv { 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 config from 'app/core/config';
import { DashboardViewState } from '../view_state_srv'; import { DashboardViewStateSrv } from './DashboardViewStateSrv';
import { DashboardModel } from '../dashboard_model'; import { DashboardModel } from '../dashboard_model';
describe('when updating view state', () => { describe('when updating view state', () => {
@ -33,7 +31,7 @@ describe('when updating view state', () => {
location.search = jest.fn(() => { location.search = jest.fn(() => {
return { fullscreen: true, edit: true, panelId: 1 }; return { fullscreen: true, edit: true, panelId: 1 };
}); });
viewState = new DashboardViewState($scope, location, {}); viewState = new DashboardViewStateSrv($scope, location, {});
}); });
it('should update querystring and view state', () => { it('should update querystring and view state', () => {
@ -55,7 +53,7 @@ describe('when updating view state', () => {
describe('to fullscreen false', () => { describe('to fullscreen false', () => {
beforeEach(() => { beforeEach(() => {
viewState = new DashboardViewState($scope, location, {}); viewState = new DashboardViewStateSrv($scope, location, {});
}); });
it('should remove params from query string', () => { it('should remove params from query string', () => {
viewState.update({ fullscreen: true, panelId: 1, edit: true }); viewState.update({ fullscreen: true, panelId: 1, edit: true });

View File

@ -2,11 +2,11 @@ import angular from 'angular';
import _ from 'lodash'; import _ from 'lodash';
import config from 'app/core/config'; import config from 'app/core/config';
import appEvents from 'app/core/app_events'; import appEvents from 'app/core/app_events';
import { DashboardModel } from './dashboard_model'; import { DashboardModel } from '../dashboard_model';
// represents the transient view state // represents the transient view state
// like fullscreen panel & edit // like fullscreen panel & edit
export class DashboardViewState { export class DashboardViewStateSrv {
state: any; state: any;
panelScopes: any; panelScopes: any;
$scope: any; $scope: any;
@ -168,7 +168,7 @@ export class DashboardViewState {
export function dashboardViewStateSrv($location, $timeout) { export function dashboardViewStateSrv($location, $timeout) {
return { return {
create: $scope => { 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 angular from 'angular';
import { ChangeTracker } from './change_tracker'; import { ChangeTracker } from './ChangeTracker';
/** @ngInject */ /** @ngInject */
export function unsavedChangesSrv(this: any, $rootScope, $q, $location, $timeout, contextSrv, dashboardSrv, $window) { 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) => { export const sharePanel = (dashboard: DashboardModel, panel: PanelModel) => {
appEvents.emit('show-modal', { appEvents.emit('show-modal', {
src: 'public/app/features/dashboard/partials/shareModal.html', src: 'public/app/features/dashboard/components/ShareModal/template.html',
model: { model: {
dashboard: dashboard, dashboard: dashboard,
panel: panel, panel: panel,

View File

@ -5,7 +5,7 @@ import { connect } from 'react-redux';
import { ExploreId, ExploreItemState } from 'app/types/explore'; import { ExploreId, ExploreItemState } from 'app/types/explore';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { toggleGraph } from './state/actions'; import { toggleTable } from './state/actions';
import Table from './Table'; import Table from './Table';
import Panel from './Panel'; import Panel from './Panel';
import TableModel from 'app/core/table_model'; import TableModel from 'app/core/table_model';
@ -16,12 +16,12 @@ interface TableContainerProps {
onClickCell: (key: string, value: string) => void; onClickCell: (key: string, value: string) => void;
showingTable: boolean; showingTable: boolean;
tableResult?: TableModel; tableResult?: TableModel;
toggleGraph: typeof toggleGraph; toggleTable: typeof toggleTable;
} }
export class TableContainer extends PureComponent<TableContainerProps> { export class TableContainer extends PureComponent<TableContainerProps> {
onClickTableButton = () => { onClickTableButton = () => {
this.props.toggleGraph(this.props.exploreId); this.props.toggleTable(this.props.exploreId);
}; };
render() { render() {
@ -43,7 +43,7 @@ function mapStateToProps(state: StoreState, { exploreId }) {
} }
const mapDispatchToProps = { const mapDispatchToProps = {
toggleGraph, toggleTable,
}; };
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(TableContainer)); 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 * Returns a fresh Explore area state
*/ */
const makeExploreItemState = (): ExploreItemState => ({ export const makeExploreItemState = (): ExploreItemState => ({
StartPage: undefined, StartPage: undefined,
containerWidth: 0, containerWidth: 0,
datasourceInstance: null, datasourceInstance: null,
@ -48,7 +48,7 @@ const makeExploreItemState = (): ExploreItemState => ({
/** /**
* Global Explore state that handles multiple Explore areas and the split state * Global Explore state that handles multiple Explore areas and the split state
*/ */
const initialExploreState: ExploreState = { export const initialExploreState: ExploreState = {
split: null, split: null,
left: makeExploreItemState(), left: makeExploreItemState(),
right: makeExploreItemState(), right: makeExploreItemState(),
@ -57,7 +57,7 @@ const initialExploreState: ExploreState = {
/** /**
* Reducer for an Explore area, to be used by the global Explore reducer. * 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) { switch (action.type) {
case ActionTypes.AddQueryRow: { case ActionTypes.AddQueryRow: {
const { initialQueries, modifiedQueries, queryTransactions } = state; const { initialQueries, modifiedQueries, queryTransactions } = state;
@ -360,13 +360,19 @@ const itemReducer = (state, action: Action): ExploreItemState => {
} }
case ActionTypes.ScanStart: { case ActionTypes.ScanStart: {
return { ...state, scanning: true }; return { ...state, scanning: true, scanner: action.payload.scanner };
} }
case ActionTypes.ScanStop: { case ActionTypes.ScanStop: {
const { queryTransactions } = state; const { queryTransactions } = state;
const nextQueryTransactions = queryTransactions.filter(qt => qt.scanning && !qt.done); 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: { case ActionTypes.SetQueries: {

View File

@ -1,5 +1,5 @@
import { DashboardImportCtrl } from '../dashboard_import_ctrl'; import { DashboardImportCtrl } from './DashboardImportCtrl';
import config from '../../../core/config'; import config from 'app/core/config';
describe('DashboardImportCtrl', () => { describe('DashboardImportCtrl', () => {
const ctx: any = {}; 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'; import locationUtil from 'app/core/utils/location_util';
export class FolderDashboardsCtrl { export class FolderDashboardsCtrl {

View File

@ -46,7 +46,7 @@ export class MoveToFolderCtrl {
export function moveToFolderModal() { export function moveToFolderModal() {
return { return {
restrict: 'E', 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, controller: MoveToFolderCtrl,
bindToController: true, bindToController: true,
controllerAs: 'ctrl', 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 */ /** @ngInject */
function uploadDashboardDirective(timer, $location) { export function uploadDashboardDirective(timer, $location) {
return { return {
restrict: 'E', restrict: 'E',
template: template, 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 { DashboardListCtrl } from './DashboardListCtrl';
import { SnapshotListCtrl } from './SnapshotListCtrl'; 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('DashboardListCtrl', DashboardListCtrl);
coreModule.controller('SnapshotListCtrl', SnapshotListCtrl); 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 _ from 'lodash';
import { toUrlParams } from 'app/core/utils/url'; import { toUrlParams } from 'app/core/utils/url';
class PlaylistSrv { export class PlaylistSrv {
private cancelPromise: any; private cancelPromise: any;
private dashboards: any; private dashboards: Array<{ uri: string }>;
private index: number; private index: number;
private interval: any; private interval: number;
private startUrl: string; private startUrl: string;
private numberOfLoops = 0;
isPlaying: boolean; isPlaying: boolean;
/** @ngInject */ /** @ngInject */
@ -20,8 +21,15 @@ class PlaylistSrv {
const playedAllDashboards = this.index > this.dashboards.length - 1; const playedAllDashboards = this.index > this.dashboards.length - 1;
if (playedAllDashboards) { if (playedAllDashboards) {
window.location.href = this.startUrl; this.numberOfLoops++;
return;
// 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]; const dash = this.dashboards[this.index];
@ -46,8 +54,8 @@ class PlaylistSrv {
this.index = 0; this.index = 0;
this.isPlaying = true; this.isPlaying = true;
this.backendSrv.get(`/api/playlists/${playlistId}`).then(playlist => { return this.backendSrv.get(`/api/playlists/${playlistId}`).then(playlist => {
this.backendSrv.get(`/api/playlists/${playlistId}/dashboards`).then(dashboards => { return this.backendSrv.get(`/api/playlists/${playlistId}/dashboards`).then(dashboards => {
this.dashboards = dashboards; this.dashboards = dashboards;
this.interval = kbn.interval_to_ms(playlist.interval); this.interval = kbn.interval_to_ms(playlist.interval);
this.next(); 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'); 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', () => { it('slash should be properly escaped in regex format', () => {
const result = _templateSrv.formatValue('Gi3/14', 'regex'); const result = _templateSrv.formatValue('Gi3/14', 'regex');
expect(result).toBe('Gi3\\/14'); expect(result).toBe('Gi3\\/14');

View File

@ -77,6 +77,15 @@ export class TemplateSrv {
return '(' + quotedValues.join(' OR ') + ')'; 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) { formatValue(value, format, variable) {
// for some scopedVars there is no variable // for some scopedVars there is no variable
variable = variable || {}; variable = variable || {};
@ -118,6 +127,13 @@ export class TemplateSrv {
} }
return value; return value;
} }
case 'percentencode': {
// like glob, but url escaped
if (_.isArray(value)) {
return this.encodeURIComponentStrict('{' + value.join(',') + '}');
}
return this.encodeURIComponentStrict(value);
}
default: { default: {
if (_.isArray(value)) { if (_.isArray(value)) {
return '{' + value.join(',') + '}'; return '{' + value.join(',') + '}';

View File

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

View File

@ -10,23 +10,6 @@
"id": null, "id": null,
"links": [], "links": [],
"panels": [ "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, "folderId": 0,
"headings": true, "headings": true,
@ -45,7 +28,7 @@
"w": 12, "w": 12,
"h": 17, "h": 17,
"x": 0, "x": 0,
"y": 6 "y": 1
} }
}, },
{ {
@ -60,7 +43,7 @@
"w": 12, "w": 12,
"h": 17, "h": 17,
"x": 12, "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} />
);
}
```