mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Show evaluation interval global limit warning (#52942)
Co-authored-by: George Robinson <george.robinson@grafana.com>
This commit is contained in:
@@ -73,6 +73,10 @@ export interface GrafanaJavascriptAgentConfig {
|
|||||||
apiKey: string;
|
apiKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UnifiedAlertingConfig {
|
||||||
|
minInterval: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Describes the plugins that should be preloaded prior to start Grafana.
|
* Describes the plugins that should be preloaded prior to start Grafana.
|
||||||
*
|
*
|
||||||
@@ -201,6 +205,7 @@ export interface GrafanaConfig {
|
|||||||
geomapDefaultBaseLayer?: MapLayerOptions;
|
geomapDefaultBaseLayer?: MapLayerOptions;
|
||||||
geomapDisableCustomBaseLayer?: boolean;
|
geomapDisableCustomBaseLayer?: boolean;
|
||||||
unifiedAlertingEnabled: boolean;
|
unifiedAlertingEnabled: boolean;
|
||||||
|
unifiedAlerting: UnifiedAlertingConfig;
|
||||||
angularSupportEnabled: boolean;
|
angularSupportEnabled: boolean;
|
||||||
feedbackLinksEnabled: boolean;
|
feedbackLinksEnabled: boolean;
|
||||||
secretsManagerPluginEnabled: boolean;
|
secretsManagerPluginEnabled: boolean;
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export class GrafanaBootConfig implements GrafanaConfig {
|
|||||||
geomapDefaultBaseLayerConfig?: MapLayerOptions;
|
geomapDefaultBaseLayerConfig?: MapLayerOptions;
|
||||||
geomapDisableCustomBaseLayer?: boolean;
|
geomapDisableCustomBaseLayer?: boolean;
|
||||||
unifiedAlertingEnabled = false;
|
unifiedAlertingEnabled = false;
|
||||||
|
unifiedAlerting = { minInterval: '' };
|
||||||
applicationInsightsConnectionString?: string;
|
applicationInsightsConnectionString?: string;
|
||||||
applicationInsightsEndpointUrl?: string;
|
applicationInsightsEndpointUrl?: string;
|
||||||
recordedQueries = {
|
recordedQueries = {
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export const getAvailableIcons = () =>
|
|||||||
'square-shape',
|
'square-shape',
|
||||||
'star',
|
'star',
|
||||||
'step-backward',
|
'step-backward',
|
||||||
|
'stopwatch-slash',
|
||||||
'sync',
|
'sync',
|
||||||
'table',
|
'table',
|
||||||
'tag-alt',
|
'tag-alt',
|
||||||
|
|||||||
@@ -179,6 +179,9 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *models.ReqContext) (map[string]i
|
|||||||
"enabled": hs.Cfg.SectionWithEnvOverrides("reporting").Key("enabled").MustBool(true),
|
"enabled": hs.Cfg.SectionWithEnvOverrides("reporting").Key("enabled").MustBool(true),
|
||||||
},
|
},
|
||||||
"unifiedAlertingEnabled": hs.Cfg.UnifiedAlerting.Enabled,
|
"unifiedAlertingEnabled": hs.Cfg.UnifiedAlerting.Enabled,
|
||||||
|
"unifiedAlerting": map[string]interface{}{
|
||||||
|
"minInterval": hs.Cfg.UnifiedAlerting.MinInterval.String(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if hs.ThumbService != nil {
|
if hs.ThumbService != nil {
|
||||||
|
|||||||
@@ -27,14 +27,15 @@ import {
|
|||||||
mockPromRuleNamespace,
|
mockPromRuleNamespace,
|
||||||
mockRulerGrafanaRule,
|
mockRulerGrafanaRule,
|
||||||
} from './mocks';
|
} from './mocks';
|
||||||
import { getAllDataSources } from './utils/config';
|
import * as config from './utils/config';
|
||||||
import { Annotation } from './utils/constants';
|
import { Annotation } from './utils/constants';
|
||||||
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||||
import * as ruleFormUtils from './utils/rule-form';
|
import * as ruleFormUtils from './utils/rule-form';
|
||||||
|
|
||||||
jest.mock('./api/prometheus');
|
jest.mock('./api/prometheus');
|
||||||
jest.mock('./api/ruler');
|
jest.mock('./api/ruler');
|
||||||
jest.mock('./utils/config');
|
|
||||||
|
jest.spyOn(config, 'getAllDataSources');
|
||||||
|
|
||||||
const dataSources = {
|
const dataSources = {
|
||||||
prometheus: mockDataSource<PromOptions>({
|
prometheus: mockDataSource<PromOptions>({
|
||||||
@@ -52,7 +53,7 @@ dataSources.prometheus.meta.alerting = true;
|
|||||||
dataSources.default.meta.alerting = true;
|
dataSources.default.meta.alerting = true;
|
||||||
|
|
||||||
const mocks = {
|
const mocks = {
|
||||||
getAllDataSources: jest.mocked(getAllDataSources),
|
getAllDataSources: jest.mocked(config.getAllDataSources),
|
||||||
api: {
|
api: {
|
||||||
fetchRules: jest.mocked(fetchRules),
|
fetchRules: jest.mocked(fetchRules),
|
||||||
fetchRulerRules: jest.mocked(fetchRulerRules),
|
fetchRulerRules: jest.mocked(fetchRulerRules),
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { discoverFeatures } from './api/buildInfo';
|
|||||||
import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
|
import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler';
|
||||||
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
|
import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor';
|
||||||
import { disableRBAC, mockDataSource, MockDataSourceSrv, mockFolder } from './mocks';
|
import { disableRBAC, mockDataSource, MockDataSourceSrv, mockFolder } from './mocks';
|
||||||
import { getAllDataSources } from './utils/config';
|
import * as config from './utils/config';
|
||||||
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||||
import { getDefaultQueries } from './utils/rule-form';
|
import { getDefaultQueries } from './utils/rule-form';
|
||||||
|
|
||||||
@@ -35,7 +35,6 @@ jest.mock('./components/rule-editor/ExpressionEditor', () => ({
|
|||||||
|
|
||||||
jest.mock('./api/buildInfo');
|
jest.mock('./api/buildInfo');
|
||||||
jest.mock('./api/ruler');
|
jest.mock('./api/ruler');
|
||||||
jest.mock('./utils/config');
|
|
||||||
jest.mock('../../../../app/features/manage-dashboards/state/actions');
|
jest.mock('../../../../app/features/manage-dashboards/state/actions');
|
||||||
|
|
||||||
// there's no angular scope in test and things go terribly wrong when trying to render the query editor row.
|
// there's no angular scope in test and things go terribly wrong when trying to render the query editor row.
|
||||||
@@ -45,8 +44,10 @@ jest.mock('app/features/query/components/QueryEditorRow', () => ({
|
|||||||
QueryEditorRow: () => <p>hi</p>,
|
QueryEditorRow: () => <p>hi</p>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.spyOn(config, 'getAllDataSources');
|
||||||
|
|
||||||
const mocks = {
|
const mocks = {
|
||||||
getAllDataSources: jest.mocked(getAllDataSources),
|
getAllDataSources: jest.mocked(config.getAllDataSources),
|
||||||
searchFolders: jest.mocked(searchFolders),
|
searchFolders: jest.mocked(searchFolders),
|
||||||
api: {
|
api: {
|
||||||
discoverFeatures: jest.mocked(discoverFeatures),
|
discoverFeatures: jest.mocked(discoverFeatures),
|
||||||
|
|||||||
@@ -30,13 +30,12 @@ import {
|
|||||||
somePromRules,
|
somePromRules,
|
||||||
someRulerRules,
|
someRulerRules,
|
||||||
} from './mocks';
|
} from './mocks';
|
||||||
import { getAllDataSources } from './utils/config';
|
import * as config from './utils/config';
|
||||||
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
|
||||||
|
|
||||||
jest.mock('./api/buildInfo');
|
jest.mock('./api/buildInfo');
|
||||||
jest.mock('./api/prometheus');
|
jest.mock('./api/prometheus');
|
||||||
jest.mock('./api/ruler');
|
jest.mock('./api/ruler');
|
||||||
jest.mock('./utils/config');
|
|
||||||
jest.mock('app/core/core', () => ({
|
jest.mock('app/core/core', () => ({
|
||||||
appEvents: {
|
appEvents: {
|
||||||
subscribe: () => {
|
subscribe: () => {
|
||||||
@@ -46,8 +45,10 @@ jest.mock('app/core/core', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.spyOn(config, 'getAllDataSources');
|
||||||
|
|
||||||
const mocks = {
|
const mocks = {
|
||||||
getAllDataSourcesMock: jest.mocked(getAllDataSources),
|
getAllDataSourcesMock: jest.mocked(config.getAllDataSources),
|
||||||
|
|
||||||
api: {
|
api: {
|
||||||
discoverFeatures: jest.mocked(discoverFeatures),
|
discoverFeatures: jest.mocked(discoverFeatures),
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import React, { FC, useState } from 'react';
|
import React, { FC, useState } from 'react';
|
||||||
import { useFormContext, RegisterOptions } from 'react-hook-form';
|
import { RegisterOptions, useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
import { parseDuration, durationToMilliseconds, GrafanaTheme2 } from '@grafana/data';
|
import { durationToMilliseconds, GrafanaTheme2, parseDuration } from '@grafana/data';
|
||||||
import { Field, InlineLabel, Input, InputControl, useStyles2 } from '@grafana/ui';
|
import { config } from '@grafana/runtime';
|
||||||
|
import { Alert, Field, InlineLabel, Input, InputControl, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { RuleFormValues } from '../../types/rule-form';
|
import { RuleFormValues } from '../../types/rule-form';
|
||||||
import { positiveDurationValidationPattern, durationValidationPattern } from '../../utils/time';
|
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
|
||||||
|
import {
|
||||||
|
durationValidationPattern,
|
||||||
|
parseDurationToMilliseconds,
|
||||||
|
positiveDurationValidationPattern,
|
||||||
|
} from '../../utils/time';
|
||||||
import { CollapseToggle } from '../CollapseToggle';
|
import { CollapseToggle } from '../CollapseToggle';
|
||||||
|
|
||||||
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
|
import { GrafanaAlertStatePicker } from './GrafanaAlertStatePicker';
|
||||||
@@ -22,10 +28,13 @@ const forValidationOptions = (evaluateEvery: string): RegisterOptions => ({
|
|||||||
},
|
},
|
||||||
pattern: durationValidationPattern,
|
pattern: durationValidationPattern,
|
||||||
validate: (value) => {
|
validate: (value) => {
|
||||||
const evaluateEveryDuration = parseDuration(evaluateEvery);
|
const millisFor = parseDurationToMilliseconds(value);
|
||||||
const forDuration = parseDuration(value);
|
const millisEvery = parseDurationToMilliseconds(evaluateEvery);
|
||||||
const millisFor = durationToMilliseconds(forDuration);
|
|
||||||
const millisEvery = durationToMilliseconds(evaluateEveryDuration);
|
// 0 is a special value meaning for equals evaluation interval
|
||||||
|
if (millisFor === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
return millisFor >= millisEvery ? true : 'For must be greater than or equal to evaluate every.';
|
return millisFor >= millisEvery ? true : 'For must be greater than or equal to evaluate every.';
|
||||||
},
|
},
|
||||||
@@ -61,6 +70,8 @@ export const GrafanaEvaluationBehavior: FC = () => {
|
|||||||
watch,
|
watch,
|
||||||
} = useFormContext<RuleFormValues>();
|
} = useFormContext<RuleFormValues>();
|
||||||
|
|
||||||
|
const { exceedsLimit: exceedsGlobalEvaluationLimit } = checkEvaluationIntervalGlobalLimit(watch('evaluateEvery'));
|
||||||
|
|
||||||
const evaluateEveryId = 'eval-every-input';
|
const evaluateEveryId = 'eval-every-input';
|
||||||
const evaluateForId = 'eval-for-input';
|
const evaluateForId = 'eval-for-input';
|
||||||
|
|
||||||
@@ -79,7 +90,15 @@ export const GrafanaEvaluationBehavior: FC = () => {
|
|||||||
>
|
>
|
||||||
Evaluate every
|
Evaluate every
|
||||||
</InlineLabel>
|
</InlineLabel>
|
||||||
<Input id={evaluateEveryId} width={8} {...register('evaluateEvery', evaluateEveryValidationOptions)} />
|
<Field
|
||||||
|
className={styles.inlineField}
|
||||||
|
error={errors.evaluateEvery?.message}
|
||||||
|
invalid={!!errors.evaluateEvery}
|
||||||
|
validationMessageHorizontalOverflow={true}
|
||||||
|
>
|
||||||
|
<Input id={evaluateEveryId} width={8} {...register('evaluateEvery', evaluateEveryValidationOptions)} />
|
||||||
|
</Field>
|
||||||
|
|
||||||
<InlineLabel
|
<InlineLabel
|
||||||
htmlFor={evaluateForId}
|
htmlFor={evaluateForId}
|
||||||
width={7}
|
width={7}
|
||||||
@@ -101,6 +120,14 @@ export const GrafanaEvaluationBehavior: FC = () => {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
{exceedsGlobalEvaluationLimit && (
|
||||||
|
<Alert severity="warning" title="Global evalutation interval limit exceeded">
|
||||||
|
A minimum evaluation interval of{' '}
|
||||||
|
<span className={styles.globalLimitValue}>{config.unifiedAlerting.minInterval}</span> has been configured in
|
||||||
|
Grafana. <br />
|
||||||
|
Please contact the administrator to configure a lower interval.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<CollapseToggle
|
<CollapseToggle
|
||||||
isCollapsed={!showErrorHandling}
|
isCollapsed={!showErrorHandling}
|
||||||
onToggle={(collapsed) => setShowErrorHandling(!collapsed)}
|
onToggle={(collapsed) => setShowErrorHandling(!collapsed)}
|
||||||
@@ -159,4 +186,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
collapseToggle: css`
|
collapseToggle: css`
|
||||||
margin: ${theme.spacing(2, 0, 2, -1)};
|
margin: ${theme.spacing(2, 0, 2, -1)};
|
||||||
`,
|
`,
|
||||||
|
globalLimitValue: css`
|
||||||
|
font-weight: ${theme.typography.fontWeightBold};
|
||||||
|
`,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data/src';
|
||||||
|
import { config } from '@grafana/runtime/src';
|
||||||
|
import { Icon, Tooltip, useStyles2 } from '@grafana/ui/src';
|
||||||
|
|
||||||
|
import { CombinedRule } from '../../../../../types/unified-alerting';
|
||||||
|
import { checkEvaluationIntervalGlobalLimit } from '../../utils/config';
|
||||||
|
|
||||||
|
interface RuleConfigStatusProps {
|
||||||
|
rule: CombinedRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuleConfigStatus({ rule }: RuleConfigStatusProps) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
|
const { exceedsLimit } = checkEvaluationIntervalGlobalLimit(rule.group.interval ?? '');
|
||||||
|
|
||||||
|
if (!exceedsLimit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
theme="error"
|
||||||
|
content={
|
||||||
|
<div>
|
||||||
|
A minimum evaluation interval of{' '}
|
||||||
|
<span className={styles.globalLimitValue}>{config.unifiedAlerting.minInterval}</span> has been configured in
|
||||||
|
Grafana and will be used instead of the {rule.group.interval} interval configured for the Rule Group.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon name="stopwatch-slash" className={styles.icon} />
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) {
|
||||||
|
return {
|
||||||
|
globalLimitValue: css`
|
||||||
|
font-weight: ${theme.typography.fontWeightBold};
|
||||||
|
`,
|
||||||
|
icon: css`
|
||||||
|
fill: ${theme.colors.warning.text};
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ interface Prom {
|
|||||||
|
|
||||||
export const RuleHealth: FC<Prom> = ({ rule }) => {
|
export const RuleHealth: FC<Prom> = ({ rule }) => {
|
||||||
const style = useStyles2(getStyle);
|
const style = useStyles2(getStyle);
|
||||||
|
|
||||||
if (rule.health === 'err' || rule.health === 'error') {
|
if (rule.health === 'err' || rule.health === 'error') {
|
||||||
return (
|
return (
|
||||||
<Tooltip theme="error" content={rule.lastError || 'No error message provided.'}>
|
<Tooltip theme="error" content={rule.lastError || 'No error message provided.'}>
|
||||||
@@ -21,6 +22,7 @@ export const RuleHealth: FC<Prom> = ({ rule }) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{rule.health}</>;
|
return <>{rule.health}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ const getStyle = (theme: GrafanaTheme2) => ({
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
color: ${theme.colors.warning.text};
|
color: ${theme.colors.warning.text};
|
||||||
|
|
||||||
& > * + * {
|
& > * + * {
|
||||||
margin-left: ${theme.spacing(1)};
|
margin-left: ${theme.spacing(1)};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ProvisioningBadge } from '../Provisioning';
|
|||||||
import { RuleLocation } from '../RuleLocation';
|
import { RuleLocation } from '../RuleLocation';
|
||||||
import { Tokenize } from '../Tokenize';
|
import { Tokenize } from '../Tokenize';
|
||||||
|
|
||||||
|
import { RuleConfigStatus } from './RuleConfigStatus';
|
||||||
import { RuleDetails } from './RuleDetails';
|
import { RuleDetails } from './RuleDetails';
|
||||||
import { RuleHealth } from './RuleHealth';
|
import { RuleHealth } from './RuleHealth';
|
||||||
import { RuleState } from './RuleState';
|
import { RuleState } from './RuleState';
|
||||||
@@ -148,11 +149,17 @@ function useColumns(showSummaryColumn: boolean, showGroupColumn: boolean) {
|
|||||||
},
|
},
|
||||||
size: '100px',
|
size: '100px',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'warnings',
|
||||||
|
label: '',
|
||||||
|
renderCell: ({ data: combinedRule }) => <RuleConfigStatus rule={combinedRule} />,
|
||||||
|
size: '45px',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'health',
|
id: 'health',
|
||||||
label: 'Health',
|
label: 'Health',
|
||||||
// eslint-disable-next-line react/display-name
|
// eslint-disable-next-line react/display-name
|
||||||
renderCell: ({ data: { promRule } }) => (promRule ? <RuleHealth rule={promRule} /> : null),
|
renderCell: ({ data: { promRule, group } }) => (promRule ? <RuleHealth rule={promRule} /> : null),
|
||||||
size: '75px',
|
size: '75px',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
|
import { DataSourceInstanceSettings, DataSourceJsonData, isValidGoDuration, rangeUtil } from '@grafana/data';
|
||||||
import { config } from '@grafana/runtime';
|
import { config } from '@grafana/runtime';
|
||||||
|
|
||||||
export function getAllDataSources(): Array<DataSourceInstanceSettings<DataSourceJsonData>> {
|
export function getAllDataSources(): Array<DataSourceInstanceSettings<DataSourceJsonData>> {
|
||||||
return Object.values(config.datasources);
|
return Object.values(config.datasources);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function checkEvaluationIntervalGlobalLimit(alertGroupEvaluateEvery: string) {
|
||||||
|
if (!isValidGoDuration(config.unifiedAlerting.minInterval)) {
|
||||||
|
return { globalLimit: 0, exceedsLimit: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const evaluateEveryMs = rangeUtil.intervalToMs(alertGroupEvaluateEvery);
|
||||||
|
const evaluateEveryGlobalLimitMs = rangeUtil.intervalToMs(config.unifiedAlerting.minInterval);
|
||||||
|
|
||||||
|
const exceedsLimit = evaluateEveryGlobalLimitMs > evaluateEveryMs && evaluateEveryMs > 0;
|
||||||
|
|
||||||
|
return { globalLimit: evaluateEveryGlobalLimitMs, exceedsLimit };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { durationToMilliseconds, parseDuration } from '@grafana/data';
|
||||||
import { describeInterval } from '@grafana/data/src/datetime/rangeutil';
|
import { describeInterval } from '@grafana/data/src/datetime/rangeutil';
|
||||||
|
|
||||||
import { TimeOptions } from '../types/time';
|
import { TimeOptions } from '../types/time';
|
||||||
@@ -35,3 +36,7 @@ export const durationValidationPattern = {
|
|||||||
TimeOptions
|
TimeOptions
|
||||||
).join(', ')}`,
|
).join(', ')}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function parseDurationToMilliseconds(duration: string) {
|
||||||
|
return durationToMilliseconds(parseDuration(duration));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user