From 72628c8ea05fe984a0341ecef6b48f9c57d1fcd8 Mon Sep 17 00:00:00 2001 From: lfroment Date: Fri, 28 Feb 2020 14:32:01 +0100 Subject: [PATCH] Dashboard: Adds support for a global minimum dashboard refresh interval (#19416) This feature would provide a way for administrators to limit the minimum dashboard refresh interval globally. Filters out the refresh intervals available in the time picker that are lower than the set minimum refresh interval in the configuration .ini file Adds the minimum refresh interval as available in the time picker. If the user tries to enter a refresh interval that is lower than the minimum in the URL, defaults to the minimum interval. When trying to update the JSON via the API, rejects the update if the dashboard's refresh interval is lower than the minimum. When trying to update a dashboard via provisioning having a lower refresh interval than the minimum, defaults to the minimum interval and logs a warning. Fixes #3356 Co-authored-by: Marcus Efraimsson --- conf/defaults.ini | 4 ++ conf/sample.ini | 4 ++ docs/sources/installation/configuration.md | 7 +++ packages/grafana-runtime/src/config.ts | 1 + .../RefreshPicker/RefreshPicker.tsx | 6 +-- pkg/api/dashboard.go | 1 + pkg/api/frontendsettings.go | 1 + pkg/models/dashboards.go | 1 + pkg/services/dashboards/dashboard_service.go | 38 ++++++++++++++++ .../dashboards/dashboard_service_test.go | 43 ++++++++++++++++++- pkg/services/sqlstore/dashboard.go | 1 + pkg/setting/setting.go | 5 +++ public/app/core/services/context_srv.ts | 20 ++++++++- .../DashNav/DashNavTimeControls.tsx | 5 ++- .../DashboardSettings/TimePickerSettings.ts | 11 +++++ .../features/dashboard/services/TimeSrv.ts | 26 +++++++++-- public/test/specs/helpers.ts | 4 ++ 17 files changed, 168 insertions(+), 10 deletions(-) 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; }