diff --git a/conf/defaults.ini b/conf/defaults.ini index 07ad63bddd8..ef7da1dbce5 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -230,6 +230,10 @@ snapshot_remove_expired = true # Number dashboard versions to keep (per dashboard). Default: 20, Minimum: 1 versions_to_keep = 20 +# Minimum dashboard refresh interval. When set, this will restrict users to set the refresh interval of a dashboard lower than given interval. Per default this is not set/unrestricted. +# The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m. +min_refresh_interval = + #################################### Users ############################### [users] # disable user signup / registration diff --git a/conf/sample.ini b/conf/sample.ini index 8dc8e4c0fac..0a5dc58e54a 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -229,6 +229,10 @@ # Number dashboard versions to keep (per dashboard). Default: 20, Minimum: 1 ;versions_to_keep = 20 +# Minimum dashboard refresh interval. When set, this will restrict users to set the refresh interval of a dashboard lower than given interval. Per default this is not set/unrestricted. +# The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. 30s or 1m. +;min_refresh_interval = + #################################### Users ############################### [users] # disable user signup / registration diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 2f5855a7231..c6ba481575d 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -502,6 +502,13 @@ Set to false to disable all checks to https://grafana.com for new versions of in Number dashboard versions to keep (per dashboard). Default: `20`, Minimum: `1`. +### min_refresh_interval + +> Only available in Grafana v6.7+. + +When set, this will restrict users to set the refresh interval of a dashboard lower than given interval. Per default this is not set/unrestricted. +The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), e.g. `30s` or `1m`. + ## [dashboards.json] > This have been replaced with dashboards [provisioning]({{< relref "../administration/provisioning" >}}) in 5.0+ diff --git a/packages/grafana-runtime/src/config.ts b/packages/grafana-runtime/src/config.ts index f5b95c43a96..392c1737764 100644 --- a/packages/grafana-runtime/src/config.ts +++ b/packages/grafana-runtime/src/config.ts @@ -30,6 +30,7 @@ interface LicenseInfo { export class GrafanaBootConfig { datasources: { [str: string]: DataSourceInstanceSettings } = {}; panels: { [key: string]: PanelPluginMeta } = {}; + minRefreshInterval = ''; appSubUrl = ''; windowTitlePrefix = ''; buildInfo: BuildInfo = {} as BuildInfo; diff --git a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx index 9492deaf3de..649054c19e2 100644 --- a/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx +++ b/packages/grafana-ui/src/components/RefreshPicker/RefreshPicker.tsx @@ -8,7 +8,7 @@ import memoizeOne from 'memoize-one'; import { GrafanaTheme } from '@grafana/data'; import { withTheme } from '../../themes'; -const defaultIntervals = ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d']; +export const defaultIntervals = ['5s', '10s', '30s', '1m', '5m', '15m', '30m', '1h', '2h', '1d']; const getStyles = memoizeOne((theme: GrafanaTheme) => { return { @@ -45,9 +45,7 @@ export class RefreshPickerBase extends PureComponent { intervalsToOptions = (intervals: string[] | undefined): Array> => { const intervalsOrDefault = intervals || defaultIntervals; - const options = intervalsOrDefault - .filter(str => str !== '') - .map(interval => ({ label: interval, value: interval })); + const options = intervalsOrDefault.map(interval => ({ label: interval, value: interval })); if (this.props.hasLiveOption) { options.unshift(RefreshPicker.liveOption); diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index f10353db342..cff14815e81 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -278,6 +278,7 @@ func dashboardSaveErrorToApiResponse(err error) Response { err == m.ErrFolderNotFound || err == m.ErrDashboardFolderCannotHaveParent || err == m.ErrDashboardFolderNameExists || + err == m.ErrDashboardRefreshIntervalTooShort || err == m.ErrDashboardCannotSaveProvisionedDashboard { return Error(400, err.Error(), nil) } diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index f75c4790439..0a74d8de0bc 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -170,6 +170,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf jsonObj := map[string]interface{}{ "defaultDatasource": defaultDatasource, "datasources": datasources, + "minRefreshInterval": setting.MinRefreshInterval, "panels": panels, "appSubUrl": setting.AppSubUrl, "allowOrgCreate": (setting.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin, diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 82cc43b25ff..8e393a156c4 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -31,6 +31,7 @@ var ( ErrDashboardInvalidUid = errors.New("uid contains illegal characters") ErrDashboardUidToLong = errors.New("uid to long. max 40 characters") ErrDashboardCannotSaveProvisionedDashboard = errors.New("Cannot save provisioned dashboard") + ErrDashboardRefreshIntervalTooShort = errors.New("Dashboard refresh interval is too low") ErrDashboardCannotDeleteProvisionedDashboard = errors.New("provisioned dashboard cannot be deleted") ErrDashboardIdentifierNotSet = errors.New("Unique identfier needed to be able to get a dashboard") RootFolderName = "General" diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go index 9aead68e0dd..b26635da39c 100644 --- a/pkg/services/dashboards/dashboard_service.go +++ b/pkg/services/dashboards/dashboard_service.go @@ -1,6 +1,8 @@ package dashboards import ( + "github.com/grafana/grafana/pkg/components/gtime" + "github.com/grafana/grafana/pkg/setting" "strings" "time" @@ -103,6 +105,10 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, return nil, models.ErrDashboardUidToLong } + if err := validateDashboardRefreshInterval(dash); err != nil { + return nil, err + } + if validateAlerts { validateAlertsCmd := models.ValidateDashboardAlertsCommand{ OrgId: dto.OrgId, @@ -172,6 +178,33 @@ func (dr *dashboardServiceImpl) buildSaveDashboardCommand(dto *SaveDashboardDTO, return cmd, nil } +func validateDashboardRefreshInterval(dash *models.Dashboard) error { + if setting.MinRefreshInterval == "" { + return nil + } + + refresh := dash.Data.Get("refresh").MustString("") + if refresh == "" { + // since no refresh is set it is a valid refresh rate + return nil + } + + minRefreshInterval, err := gtime.ParseInterval(setting.MinRefreshInterval) + if err != nil { + return err + } + d, err := gtime.ParseInterval(refresh) + if err != nil { + return err + } + + if d < minRefreshInterval { + return models.ErrDashboardRefreshIntervalTooShort + } + + return nil +} + func (dr *dashboardServiceImpl) updateAlerting(cmd *models.SaveDashboardCommand, dto *SaveDashboardDTO) error { alertCmd := models.UpdateDashboardAlertsCommand{ OrgId: dto.OrgId, @@ -183,6 +216,11 @@ func (dr *dashboardServiceImpl) updateAlerting(cmd *models.SaveDashboardCommand, } func (dr *dashboardServiceImpl) SaveProvisionedDashboard(dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { + if err := validateDashboardRefreshInterval(dto.Dashboard); err != nil { + dr.log.Warn("Changing refresh interval for provisioned dashboard to minimum refresh interval", "dashboardUid", dto.Dashboard.Uid, "dashboardTitle", dto.Dashboard.Title, "minRefreshInterval", setting.MinRefreshInterval) + dto.Dashboard.Data.Set("refresh", setting.MinRefreshInterval) + } + dto.User = &models.SignedInUser{ UserId: 0, OrgRole: models.ROLE_ADMIN, diff --git a/pkg/services/dashboards/dashboard_service_test.go b/pkg/services/dashboards/dashboard_service_test.go index 36e910d8360..ea7efbb9c1c 100644 --- a/pkg/services/dashboards/dashboard_service_test.go +++ b/pkg/services/dashboards/dashboard_service_test.go @@ -1,6 +1,8 @@ package dashboards import ( + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/setting" "testing" "github.com/grafana/grafana/pkg/bus" @@ -14,7 +16,9 @@ func TestDashboardService(t *testing.T) { Convey("Dashboard service tests", t, func() { bus.ClearBusHandlers() - service := &dashboardServiceImpl{} + service := &dashboardServiceImpl{ + log: log.New("test.logger"), + } origNewDashboardGuardian := guardian.New guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanSaveValue: true}) @@ -184,6 +188,43 @@ func TestDashboardService(t *testing.T) { So(err, ShouldBeNil) So(provisioningValidated, ShouldBeFalse) }) + + Convey("Should override invalid refresh interval if dashboard is provisioned", func() { + oldRefreshInterval := setting.MinRefreshInterval + setting.MinRefreshInterval = "5m" + defer func() { setting.MinRefreshInterval = oldRefreshInterval }() + + bus.AddHandler("test", func(cmd *models.GetProvisionedDashboardDataByIdQuery) error { + cmd.Result = &models.DashboardProvisioning{} + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardAlertsCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.ValidateDashboardBeforeSaveCommand) error { + cmd.Result = &models.ValidateDashboardBeforeSaveResult{} + return nil + }) + + bus.AddHandler("test", func(cmd *models.SaveProvisionedDashboardCommand) error { + return nil + }) + + bus.AddHandler("test", func(cmd *models.UpdateDashboardAlertsCommand) error { + return nil + }) + + dto.Dashboard = models.NewDashboard("Dash") + dto.Dashboard.SetId(3) + dto.User = &models.SignedInUser{UserId: 1} + dto.Dashboard.Data.Set("refresh", "1s") + _, err := service.SaveProvisionedDashboard(dto, nil) + So(err, ShouldBeNil) + So(dto.Dashboard.Data.Get("refresh").MustString(), ShouldEqual, "5m") + + }) }) Convey("Import dashboard validation", func() { diff --git a/pkg/services/sqlstore/dashboard.go b/pkg/services/sqlstore/dashboard.go index 0206269c135..598b039c4be 100644 --- a/pkg/services/sqlstore/dashboard.go +++ b/pkg/services/sqlstore/dashboard.go @@ -623,6 +623,7 @@ func getExistingDashboardByTitleAndFolder(sess *DBSession, cmd *models.ValidateD func ValidateDashboardBeforeSave(cmd *models.ValidateDashboardBeforeSaveCommand) (err error) { cmd.Result = &models.ValidateDashboardBeforeSaveResult{} + return inTransaction(func(sess *DBSession) error { if err = getExistingDashboardByIdOrUidForUpdate(sess, cmd); err != nil { return err diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index 19181be84e1..ddcb761d5d6 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -119,6 +119,7 @@ var ( // Dashboard history DashboardVersionsToKeep int + MinRefreshInterval string // User settings AllowUserSignUp bool @@ -763,6 +764,10 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { // read dashboard settings dashboards := iniFile.Section("dashboards") DashboardVersionsToKeep = dashboards.Key("versions_to_keep").MustInt(20) + MinRefreshInterval, err = valueAsString(dashboards, "min_refresh_interval", "") + if err != nil { + return err + } // read data source proxy white list DataProxyWhiteList = make(map[string]bool) diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index 99d96271ce5..b6a3c8f08cc 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -1,6 +1,7 @@ -import config from 'app/core/config'; +import config from '../../core/config'; import _ from 'lodash'; import coreModule from 'app/core/core_module'; +import kbn from '../utils/kbn'; export class User { id: number; @@ -32,6 +33,7 @@ export class ContextSrv { isEditor: any; sidemenuSmallBreakpoint = false; hasEditPermissionInFolders: boolean; + minRefreshInterval: string; constructor() { if (!config.bootData) { @@ -43,6 +45,7 @@ export class ContextSrv { this.isGrafanaAdmin = this.user.isGrafanaAdmin; this.isEditor = this.hasRole('Editor') || this.hasRole('Admin'); this.hasEditPermissionInFolders = this.user.hasEditPermissionInFolders; + this.minRefreshInterval = config.minRefreshInterval; } hasRole(role: string) { @@ -53,6 +56,21 @@ export class ContextSrv { return !!(document.visibilityState === undefined || document.visibilityState === 'visible'); } + // checks whether the passed interval is longer than the configured minimum refresh rate + isAllowedInterval(interval: string) { + if (!config.minRefreshInterval) { + return true; + } + return kbn.interval_to_ms(interval) >= kbn.interval_to_ms(config.minRefreshInterval); + } + + getValidInterval(interval: string) { + if (!this.isAllowedInterval(interval)) { + return config.minRefreshInterval; + } + return interval; + } + hasAccessToExplore() { return (this.isEditor || config.viewersCanEdit) && config.exploreEnabled; } diff --git a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx index 4d6ba3b6c15..6e5f1721afd 100644 --- a/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNavTimeControls.tsx @@ -17,6 +17,7 @@ import { TimePickerWithHistory } from 'app/core/components/TimePicker/TimePicker // Utils & Services import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { defaultIntervals } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; import { appEvents } from 'app/core/core'; const getStyles = stylesFactory((theme: GrafanaTheme) => { @@ -92,7 +93,9 @@ class UnthemedDashNavTimeControls extends Component { render() { const { dashboard, theme } = this.props; - const intervals = dashboard.timepicker.refresh_intervals; + const { refresh_intervals } = dashboard.timepicker; + const intervals = getTimeSrv().getValidIntervals(refresh_intervals || defaultIntervals); + const timePickerValue = getTimeSrv().timeRange(); const timeZone = dashboard.getTimezone(); const styles = getStyles(theme); diff --git a/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.ts b/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.ts index f106dfd88f2..0f6a4a6a242 100644 --- a/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.ts +++ b/public/app/features/dashboard/components/DashboardSettings/TimePickerSettings.ts @@ -1,5 +1,7 @@ import coreModule from 'app/core/core_module'; import { DashboardModel } from 'app/features/dashboard/state'; +import { config } from 'app/core/config'; +import kbn from 'app/core/utils/kbn'; export class TimePickerCtrl { panel: any; @@ -19,6 +21,15 @@ export class TimePickerCtrl { '2h', '1d', ]; + if (config.minRefreshInterval) { + this.panel.refresh_intervals = this.filterRefreshRates(this.panel.refresh_intervals); + } + } + + filterRefreshRates(refreshRates: string[]) { + return refreshRates.filter(rate => { + return kbn.interval_to_ms(rate) > kbn.interval_to_ms(config.minRefreshInterval); + }); } } diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts index b178e98a53a..7d2e522fece 100644 --- a/public/app/features/dashboard/services/TimeSrv.ts +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -22,6 +22,8 @@ import { getZoomedTimeRange, getShiftedTimeRange } from 'app/core/utils/timePick import { appEvents } from '../../../core/core'; import { CoreEvents } from '../../../types'; +import { config } from 'app/core/config'; + export class TimeSrv { time: any; refreshTimer: any; @@ -72,6 +74,19 @@ export class TimeSrv { } } + getValidIntervals(intervals: string[]): string[] { + if (!this.contextSrv.minRefreshInterval) { + return intervals; + } + + const validIntervals = intervals.filter(str => str !== '').filter(this.contextSrv.isAllowedInterval); + + if (validIntervals.indexOf(this.contextSrv.minRefreshInterval) === -1) { + validIntervals.unshift(this.contextSrv.minRefreshInterval); + } + return validIntervals; + } + private parseTime() { // when absolute time is saved in json it is turned to a string if (_.isString(this.time.from) && this.time.from.indexOf('Z') >= 0) { @@ -138,7 +153,11 @@ export class TimeSrv { } // but if refresh explicitly set then use that if (params.refresh) { - this.refresh = params.refresh || this.refresh; + if (!this.contextSrv.isAllowedInterval(params.refresh)) { + this.refresh = config.minRefreshInterval; + } else { + this.refresh = params.refresh || this.refresh; + } } } @@ -170,7 +189,8 @@ export class TimeSrv { this.cancelNextRefresh(); if (interval) { - const intervalMs = kbn.interval_to_ms(interval); + const validInterval = this.contextSrv.getValidInterval(interval); + const intervalMs = kbn.interval_to_ms(validInterval); this.refreshTimer = this.timer.register( this.$timeout(() => { @@ -184,7 +204,7 @@ export class TimeSrv { this.$timeout(() => { const params = this.$location.search(); if (interval) { - params.refresh = interval; + params.refresh = this.contextSrv.getValidInterval(interval); this.$location.search(params); } else if (params.refresh) { delete params.refresh; diff --git a/public/test/specs/helpers.ts b/public/test/specs/helpers.ts index 628d00692af..243d8ac07a3 100644 --- a/public/test/specs/helpers.ts +++ b/public/test/specs/helpers.ts @@ -177,6 +177,10 @@ export class TimeSrvStub { export class ContextSrvStub { isGrafanaVisibile = jest.fn(); + getValidInterval() { + return '10s'; + } + hasRole() { return true; }