Merge remote-tracking branch 'origin/main' into grafana-git-ui-sync

This commit is contained in:
Ryan McKinley committed 2025-02-21 08:08:34 +03:00
commit c7849f4545
29 files changed
+780 -757

No files matched your search

@@ -209,3 +209,7 @@ When adding or editing an annotation, you can define a repeating time region by
The above configuration produces the following result in the Time series panel:
{{< figure src="/media/docs/grafana/screenshot-grafana-10-0-timeseries-time-regions.png" max-width="600px" alt="Time series visualization with time regions business hours" >}}
Toggle the **Advanced** switch and use [Cron syntax](https://crontab.run/) to set more granular time region controls. The following example sets a time region of 9:00 AM, Monday to Friday:
{{< figure src="/media/docs/grafana/dashboards/screenshot-annotations-cron-option-v11.6.png" max-width="600px" alt="Time region query with cron syntax" >}}
+1
View File
@@ -320,6 +320,7 @@
"combokeys": "^3.0.0",
"comlink": "4.4.2",
"common-tags": "1.8.2",
"croner": "^9.0.0",
"d3": "7.9.0",
"d3-force": "3.0.0",
"d3-scale-chromatic": "3.1.0",
@@ -26,6 +26,10 @@ export function intervalToAbbreviatedDurationString(interval: Interval, includeS
}
const duration = intervalToDuration(interval);
return reverseParseDuration(duration, includeSeconds);
}
export function reverseParseDuration(duration: Duration, includeSeconds: boolean): string {
return Object.entries(duration).reduce((str, [unit, value]) => {
if (value && value !== 0 && !(unit === 'seconds' && !includeSeconds && str)) {
const padding = str !== '' ? ' ' : '';
+1
View File
@@ -403,6 +403,7 @@ export { type DateTimeOptionsWhenParsing, dateTimeParse } from './datetime/parse
export {
intervalToAbbreviatedDurationString,
parseDuration,
reverseParseDuration,
addDurationToDate,
durationToMilliseconds,
isValidDate,
+2 -2
View File
@@ -210,8 +210,8 @@ func SetDualWritingMode(
// Once we are done with running the syncer, we can change the mode back on the config to the desired one.
cfg.Mode = cfgModeTmp
if err != nil {
klog.Info("data syncer failed for mode:", m)
return Mode0, err
klog.Error("data syncer failed for mode:", m, "err", err)
return currentMode, nil
}
if !syncOk {
klog.Info("data syncer not ok for mode:", m)
+22 -6
View File
@@ -2,6 +2,7 @@ package rest
import (
"context"
"fmt"
"testing"
"time"
@@ -15,10 +16,11 @@ import (
func TestSetDualWritingMode(t *testing.T) {
type testCase struct {
name string
kvStore *fakeNamespacedKV
desiredMode DualWriterMode
expectedMode DualWriterMode
name string
kvStore *fakeNamespacedKV
desiredMode DualWriterMode
expectedMode DualWriterMode
serverLockError error
}
tests :=
[]testCase{
@@ -52,6 +54,13 @@ func TestSetDualWritingMode(t *testing.T) {
desiredMode: Mode0,
expectedMode: Mode0,
},
{
name: "should keep mode2 when trying to go from mode2 to mode3 and the server lock service returns an error",
kvStore: &fakeNamespacedKV{data: map[string]string{"playlist.grafana.app/playlists": "2"}, namespace: "storage.dualwriting"},
desiredMode: Mode3,
expectedMode: Mode2,
serverLockError: fmt.Errorf("lock already exists"),
},
}
for _, tt := range tests {
@@ -68,12 +77,16 @@ func TestSetDualWritingMode(t *testing.T) {
lm.On("List", mock.Anything, mock.Anything).Return(exampleList, nil)
ls := legacyStoreMock{lm, l}
serverLockSvc := &fakeServerLock{
err: tt.serverLockError,
}
dwMode, err := SetDualWritingMode(context.Background(), tt.kvStore, &SyncerConfig{
LegacyStorage: ls,
Storage: us,
Kind: "playlist.grafana.app/playlists",
Mode: tt.desiredMode,
ServerLockService: &fakeServerLock{},
ServerLockService: serverLockSvc,
RequestInfo: &request.RequestInfo{},
Reg: p,
@@ -144,11 +157,14 @@ func (f *fakeNamespacedKV) Set(ctx context.Context, key, value string) error {
return nil
}
// Never lock in tests
type fakeServerLock struct {
err error
}
func (f *fakeServerLock) LockExecuteAndRelease(ctx context.Context, actionName string, duration time.Duration, fn func(ctx context.Context)) error {
if f.err != nil {
return f.err
}
fn(ctx)
return nil
}
@@ -144,6 +144,11 @@ func CreateAggregatorConfig(commandOptions *options.Options, sharedConfig generi
aggregatorConfig.ExtraConfig.ProxyClientKeyFile = commandOptions.KubeAggregatorOptions.ProxyClientKeyFile
}
customExtraConfig := &CustomExtraConfig{
DiscoveryOnlyProxyClientCertFile: commandOptions.KubeAggregatorOptions.ProxyClientCertFile,
DiscoveryOnlyProxyClientKeyFile: commandOptions.KubeAggregatorOptions.ProxyClientKeyFile,
}
if err := commandOptions.KubeAggregatorOptions.ApplyTo(aggregatorConfig, commandOptions.RecommendedOptions.Etcd); err != nil {
return nil, err
}
@@ -156,7 +161,7 @@ func CreateAggregatorConfig(commandOptions *options.Options, sharedConfig generi
// Exit early, if no remote services file is configured
if commandOptions.KubeAggregatorOptions.RemoteServicesFile == "" {
return NewConfig(aggregatorConfig, sharedInformerFactory, []builder.APIGroupBuilder{serviceAPIBuilder}, nil), nil
return NewConfig(aggregatorConfig, customExtraConfig, sharedInformerFactory, []builder.APIGroupBuilder{serviceAPIBuilder}, nil), nil
}
remoteServices, err := ReadRemoteServices(commandOptions.KubeAggregatorOptions.RemoteServicesFile)
@@ -176,9 +181,11 @@ func CreateAggregatorConfig(commandOptions *options.Options, sharedConfig generi
serviceClientSet: serviceClient,
}
return NewConfig(aggregatorConfig, sharedInformerFactory, []builder.APIGroupBuilder{serviceAPIBuilder}, remoteServicesConfig), nil
return NewConfig(aggregatorConfig, customExtraConfig, sharedInformerFactory, []builder.APIGroupBuilder{serviceAPIBuilder}, remoteServicesConfig), nil
}
// CreateAggregatorServer creates an aggregated server to layer into the existing apiserver
// TODO: passing options temporarily as that allows us to pass in cert/key for client into AvailableController but skip it in the aggregator lib
func CreateAggregatorServer(config *Config, delegateAPIServer genericapiserver.DelegationTarget, reg prometheus.Registerer) (*aggregatorapiserver.APIAggregator, error) {
aggregatorConfig := config.KubeAggregatorConfig
sharedInformerFactory := config.Informers
@@ -257,8 +264,8 @@ func CreateAggregatorServer(config *Config, delegateAPIServer genericapiserver.D
proxyCurrentCertKeyContentFunc := func() ([]byte, []byte) {
return nil, nil
}
if len(config.KubeAggregatorConfig.ExtraConfig.ProxyClientCertFile) > 0 && len(config.KubeAggregatorConfig.ExtraConfig.ProxyClientKeyFile) > 0 {
aggregatorProxyCerts, err := dynamiccertificates.NewDynamicServingContentFromFiles("aggregator-proxy-cert", config.KubeAggregatorConfig.ExtraConfig.ProxyClientCertFile, config.KubeAggregatorConfig.ExtraConfig.ProxyClientKeyFile)
if len(config.CustomExtraConfig.DiscoveryOnlyProxyClientCertFile) > 0 && len(config.CustomExtraConfig.DiscoveryOnlyProxyClientKeyFile) > 0 {
aggregatorProxyCerts, err := dynamiccertificates.NewDynamicServingContentFromFiles("aggregator-proxy-cert", config.CustomExtraConfig.DiscoveryOnlyProxyClientCertFile, config.CustomExtraConfig.DiscoveryOnlyProxyClientKeyFile)
if err != nil {
return nil, err
}
+8 -1
View File
@@ -28,8 +28,14 @@ type RemoteServicesConfig struct {
serviceClientSet *serviceclientset.Clientset
}
type CustomExtraConfig struct {
DiscoveryOnlyProxyClientCertFile string
DiscoveryOnlyProxyClientKeyFile string
}
type Config struct {
KubeAggregatorConfig *aggregatorapiserver.Config
CustomExtraConfig *CustomExtraConfig // this is temporary and will be removed once we have moved across newer auth rollout in cloud
Informers informersv0alpha1.SharedInformerFactory
RemoteServicesConfig *RemoteServicesConfig
// Builders contain prerequisite api groups for aggregator to function correctly e.g. ExternalName
@@ -40,7 +46,7 @@ type Config struct {
}
// remoteServices may be nil when not using aggregation
func NewConfig(aggregator *aggregatorapiserver.Config, informers informersv0alpha1.SharedInformerFactory, builders []builder.APIGroupBuilder, remoteServices *RemoteServicesConfig) *Config {
func NewConfig(aggregator *aggregatorapiserver.Config, customExtraConfig *CustomExtraConfig, informers informersv0alpha1.SharedInformerFactory, builders []builder.APIGroupBuilder, remoteServices *RemoteServicesConfig) *Config {
getMergedOpenAPIDefinitions := func(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition {
aggregatorAPIs := aggregatoropenapi.GetOpenAPIDefinitions(ref)
builderAPIs := builder.GetOpenAPIDefinitions(builders)(ref)
@@ -61,6 +67,7 @@ func NewConfig(aggregator *aggregatorapiserver.Config, informers informersv0alph
return &Config{
aggregator,
customExtraConfig,
informers,
remoteServices,
builders,
+1
View File
@@ -43,6 +43,7 @@ func applyGrafanaConfig(cfg *setting.Cfg, features featuremgmt.FeatureToggles, o
o.KubeAggregatorOptions.ProxyClientCertFile = apiserverCfg.Key("proxy_client_cert_file").MustString("")
o.KubeAggregatorOptions.ProxyClientKeyFile = apiserverCfg.Key("proxy_client_key_file").MustString("")
o.KubeAggregatorOptions.LegacyClientCertAuth = apiserverCfg.Key("legacy_client_cert_auth").MustBool(true)
o.KubeAggregatorOptions.APIServiceCABundleFile = apiserverCfg.Key("apiservice_ca_bundle_file").MustString("")
o.KubeAggregatorOptions.RemoteServicesFile = apiserverCfg.Key("remote_services_file").MustString("")
+1
View File
@@ -48,6 +48,7 @@ func (o *ExtraOptions) ApplyTo(c *genericapiserver.RecommendedConfig) error {
}); err != nil {
return err
}
// TODO: klog isn't working as expected, investigate - it logs some of the time
klog.SetSlogLogger(logger)
if _, err := logs.GlogSetter(strconv.Itoa(o.Verbosity)); err != nil {
logger.Error("failed to set log level", "error", err)
@@ -48,7 +48,7 @@ func (o *KubeAggregatorOptions) AddFlags(fs *pflag.FlagSet) {
fs.StringVar(&o.ProxyClientKeyFile, "proxy-client-key-file", o.ProxyClientKeyFile,
"path to proxy client key file")
fs.BoolVar(&o.LegacyClientCertAuth, "legacy_client_cert_auth", true,
fs.BoolVar(&o.LegacyClientCertAuth, "legacy-client-cert-auth", true,
"whether to use legacy client cert auth")
}
+4 -4
View File
@@ -877,7 +877,7 @@ var (
},
{
Name: "exploreMetrics",
Description: "Enables the new Explore Metrics core app",
Description: "Enables the new Grafana Metrics Drilldown core app",
Stage: FeatureStageGeneralAvailability,
Expression: "true", // enabled by default
FrontendOnly: true,
@@ -1346,21 +1346,21 @@ var (
},
{
Name: "exploreLogsShardSplitting",
Description: "Used in Explore Logs to split queries into multiple queries based on the number of shards",
Description: "Used in Logs Drilldown to split queries into multiple queries based on the number of shards",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "exploreLogsAggregatedMetrics",
Description: "Used in Explore Logs to query by aggregated metrics",
Description: "Used in Logs Drilldown to query by aggregated metrics",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "exploreLogsLimitedTimeRange",
Description: "Used in Explore Logs to limit the time range",
Description: "Used in Logs Drilldown to limit the time range",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaObservabilityLogsSquad,
+4 -4
View File
@@ -476,7 +476,7 @@ const (
FlagLogsInfiniteScrolling = "logsInfiniteScrolling"
// FlagExploreMetrics
// Enables the new Explore Metrics core app
// Enables the new Grafana Metrics Drilldown core app
FlagExploreMetrics = "exploreMetrics"
// FlagAlertingSimplifiedRouting
@@ -716,15 +716,15 @@ const (
FlagAlertingPrometheusRulesPrimary = "alertingPrometheusRulesPrimary"
// FlagExploreLogsShardSplitting
// Used in Explore Logs to split queries into multiple queries based on the number of shards
// Used in Logs Drilldown to split queries into multiple queries based on the number of shards
FlagExploreLogsShardSplitting = "exploreLogsShardSplitting"
// FlagExploreLogsAggregatedMetrics
// Used in Explore Logs to query by aggregated metrics
// Used in Logs Drilldown to query by aggregated metrics
FlagExploreLogsAggregatedMetrics = "exploreLogsAggregatedMetrics"
// FlagExploreLogsLimitedTimeRange
// Used in Explore Logs to limit the time range
// Used in Logs Drilldown to limit the time range
FlagExploreLogsLimitedTimeRange = "exploreLogsLimitedTimeRange"
// FlagHomeSetupGuide
+21 -12
View File
@@ -1544,11 +1544,14 @@
{
"metadata": {
"name": "exploreLogsAggregatedMetrics",
"resourceVersion": "1724938092041",
"creationTimestamp": "2024-08-29T13:55:59Z"
"resourceVersion": "1740084492165",
"creationTimestamp": "2024-08-29T13:55:59Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-02-20 20:48:12.165306 +0000 UTC"
}
},
"spec": {
"description": "Used in Explore Logs to query by aggregated metrics",
"description": "Used in Logs Drilldown to query by aggregated metrics",
"stage": "experimental",
"codeowner": "@grafana/observability-logs",
"frontend": true
@@ -1557,11 +1560,14 @@
{
"metadata": {
"name": "exploreLogsLimitedTimeRange",
"resourceVersion": "1724938092041",
"creationTimestamp": "2024-08-29T13:55:59Z"
"resourceVersion": "1740084492165",
"creationTimestamp": "2024-08-29T13:55:59Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-02-20 20:48:12.165306 +0000 UTC"
}
},
"spec": {
"description": "Used in Explore Logs to limit the time range",
"description": "Used in Logs Drilldown to limit the time range",
"stage": "experimental",
"codeowner": "@grafana/observability-logs",
"frontend": true
@@ -1570,11 +1576,14 @@
{
"metadata": {
"name": "exploreLogsShardSplitting",
"resourceVersion": "1724938092041",
"creationTimestamp": "2024-08-29T13:55:59Z"
"resourceVersion": "1740084492165",
"creationTimestamp": "2024-08-29T13:55:59Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-02-20 20:48:12.165306 +0000 UTC"
}
},
"spec": {
"description": "Used in Explore Logs to split queries into multiple queries based on the number of shards",
"description": "Used in Logs Drilldown to split queries into multiple queries based on the number of shards",
"stage": "experimental",
"codeowner": "@grafana/observability-logs",
"frontend": true
@@ -1583,14 +1592,14 @@
{
"metadata": {
"name": "exploreMetrics",
"resourceVersion": "1737658563230",
"resourceVersion": "1740084233934",
"creationTimestamp": "2024-04-09T18:15:18Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-01-23 18:56:03.23086 +0000 UTC"
"grafana.app/updatedTimestamp": "2025-02-20 20:43:53.934892 +0000 UTC"
}
},
"spec": {
"description": "Enables the new Explore Metrics core app",
"description": "Enables the new Grafana Metrics Drilldown core app",
"stage": "GA",
"codeowner": "@grafana/observability-metrics",
"frontend": true,
+309 -92
View File
@@ -1,19 +1,84 @@
import { dateTime, TimeRange } from '@grafana/data';
import { TimeRegionConfig } from 'app/core/utils/timeRegions';
import { Duration } from 'date-fns';
import { AbsoluteTimeRange, dateTimeForTimeZone, reverseParseDuration, TimeRange } from '@grafana/data';
import { convertToCron, TimeRegionConfig } from 'app/core/utils/timeRegions';
import { calculateTimesWithin } from './timeRegions';
// note: calculateTimesWithin always returns time ranges in UTC
// random from the interwebs
function durationFromSeconds(seconds: number): Duration {
const secondsInYear = 31536000;
const secondsInMonth = 2628000;
const secondsInDay = 86400;
const secondsInHour = 3600;
const secondsInMinute = 60;
let years = Math.floor(seconds / secondsInYear);
let remainingSeconds = seconds % secondsInYear;
let months = Math.floor(remainingSeconds / secondsInMonth);
remainingSeconds %= secondsInMonth;
let days = Math.floor(remainingSeconds / secondsInDay);
remainingSeconds %= secondsInDay;
let hours = Math.floor(remainingSeconds / secondsInHour);
remainingSeconds %= secondsInHour;
let minutes = Math.floor(remainingSeconds / secondsInMinute);
let finalSeconds = remainingSeconds % secondsInMinute;
return {
years,
months,
days,
hours,
minutes,
seconds: finalSeconds,
};
}
function tsToDayOfWeek(ts: number, tz?: string) {
return new Date(ts).toLocaleString('en', {
timeZone: tz,
weekday: 'short',
});
}
function tsToDateTimeString(ts: number, tz?: string) {
return new Date(ts).toLocaleString('sv', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZone: tz,
timeZoneName: 'short',
});
}
function formatAbsoluteRange(range: AbsoluteTimeRange, tz?: string) {
return {
fr: `${tsToDayOfWeek(range.from, tz)} | ${tsToDateTimeString(range.from, tz)}`.replaceAll('−', '-'),
to: `${tsToDayOfWeek(range.to, tz)} | ${tsToDateTimeString(range.to, tz)}`.replaceAll('−', '-'),
};
}
describe('timeRegions', () => {
describe('day of week', () => {
it('returns regions with 4 Mondays in March 2023', () => {
const dashboardTz = 'America/Chicago';
const regionsTz = dashboardTz;
const cfg: TimeRegionConfig = {
timezone: regionsTz,
fromDayOfWeek: 1,
};
const tr: TimeRange = {
from: dateTime('2023-03-01'),
to: dateTime('2023-03-31'),
from: dateTimeForTimeZone(dashboardTz, '2023-03-01'),
to: dateTimeForTimeZone(dashboardTz, '2023-03-31'),
raw: {
to: '',
from: '',
@@ -21,38 +86,41 @@ describe('timeRegions', () => {
};
const regions = calculateTimesWithin(cfg, tr);
expect(regions).toMatchInlineSnapshot(`
[
{
"from": 1678060800000,
"to": 1678147199000,
},
{
"from": 1678665600000,
"to": 1678751999000,
},
{
"from": 1679270400000,
"to": 1679356799000,
},
{
"from": 1679875200000,
"to": 1679961599000,
},
]
`);
const formatted = regions.map((r) => formatAbsoluteRange(r, regionsTz));
expect(formatted).toEqual([
{
fr: 'Mon | 2023-03-06 00:00:00 GMT-6',
to: 'Tue | 2023-03-07 00:00:00 GMT-6',
},
{
fr: 'Mon | 2023-03-13 00:00:00 GMT-5',
to: 'Tue | 2023-03-14 00:00:00 GMT-5',
},
{
fr: 'Mon | 2023-03-20 00:00:00 GMT-5',
to: 'Tue | 2023-03-21 00:00:00 GMT-5',
},
{
fr: 'Mon | 2023-03-27 00:00:00 GMT-5',
to: 'Tue | 2023-03-28 00:00:00 GMT-5',
},
]);
});
});
describe('day and time of week', () => {
it('returns regions with 4 Mondays at 20:00 in March 2023', () => {
const dashboardTz = 'America/Chicago';
const regionsTz = dashboardTz;
const cfg: TimeRegionConfig = {
timezone: regionsTz,
fromDayOfWeek: 1,
from: '20:00',
};
const tr: TimeRange = {
from: dateTime('2023-03-01'),
to: dateTime('2023-03-31'),
from: dateTimeForTimeZone(dashboardTz, '2023-03-01'),
to: dateTimeForTimeZone(dashboardTz, '2023-03-31'),
raw: {
to: '',
from: '',
@@ -60,38 +128,41 @@ describe('timeRegions', () => {
};
const regions = calculateTimesWithin(cfg, tr);
expect(regions).toMatchInlineSnapshot(`
[
{
"from": 1678132800000,
"to": 1678132800000,
},
{
"from": 1678737600000,
"to": 1678737600000,
},
{
"from": 1679342400000,
"to": 1679342400000,
},
{
"from": 1679947200000,
"to": 1679947200000,
},
]
`);
const formatted = regions.map((r) => formatAbsoluteRange(r, regionsTz));
expect(formatted).toEqual([
{
fr: 'Mon | 2023-03-06 20:00:00 GMT-6',
to: 'Mon | 2023-03-06 20:00:00 GMT-6',
},
{
fr: 'Mon | 2023-03-13 20:00:00 GMT-5',
to: 'Mon | 2023-03-13 20:00:00 GMT-5',
},
{
fr: 'Mon | 2023-03-20 20:00:00 GMT-5',
to: 'Mon | 2023-03-20 20:00:00 GMT-5',
},
{
fr: 'Mon | 2023-03-27 20:00:00 GMT-5',
to: 'Mon | 2023-03-27 20:00:00 GMT-5',
},
]);
});
});
describe('day of week range', () => {
it('returns regions with days range', () => {
const dashboardTz = 'America/Chicago';
const regionsTz = dashboardTz;
const cfg: TimeRegionConfig = {
timezone: regionsTz,
fromDayOfWeek: 1,
toDayOfWeek: 3,
};
const tr: TimeRange = {
from: dateTime('2023-03-01'),
to: dateTime('2023-03-31'),
from: dateTimeForTimeZone(dashboardTz, '2023-03-01'),
to: dateTimeForTimeZone(dashboardTz, '2023-03-31'),
raw: {
to: '',
from: '',
@@ -99,29 +170,82 @@ describe('timeRegions', () => {
};
const regions = calculateTimesWithin(cfg, tr);
expect(regions).toMatchInlineSnapshot(`
[
{
"from": 1678060800000,
"to": 1678319999000,
},
{
"from": 1678665600000,
"to": 1678924799000,
},
{
"from": 1679270400000,
"to": 1679529599000,
},
{
"from": 1679875200000,
"to": 1680134399000,
},
]
`);
const formatted = regions.map((r) => formatAbsoluteRange(r, regionsTz));
expect(formatted).toEqual([
{
fr: 'Mon | 2023-02-27 00:00:00 GMT-6',
to: 'Thu | 2023-03-02 00:00:00 GMT-6',
},
{
fr: 'Mon | 2023-03-06 00:00:00 GMT-6',
to: 'Thu | 2023-03-09 00:00:00 GMT-6',
},
{
fr: 'Mon | 2023-03-13 00:00:00 GMT-5',
to: 'Thu | 2023-03-16 00:00:00 GMT-5',
},
{
fr: 'Mon | 2023-03-20 00:00:00 GMT-5',
to: 'Thu | 2023-03-23 00:00:00 GMT-5',
},
{
fr: 'Mon | 2023-03-27 00:00:00 GMT-5',
to: 'Thu | 2023-03-30 00:00:00 GMT-5',
},
]);
});
it('returns regions with days/times range', () => {
it('returns regions with days range (browser time zone)', () => {
const dashboardTz = process.env.TZ;
const regionsTz = dashboardTz;
const cfg: TimeRegionConfig = {
timezone: regionsTz,
fromDayOfWeek: 1,
toDayOfWeek: 3,
};
const tr: TimeRange = {
from: dateTimeForTimeZone(dashboardTz, '2023-03-01'),
to: dateTimeForTimeZone(dashboardTz, '2023-03-31'),
raw: {
to: '',
from: '',
},
};
const regions = calculateTimesWithin(cfg, tr);
const formatted = regions.map((r) => formatAbsoluteRange(r, regionsTz));
expect(formatted).toEqual([
{
fr: 'Mon | 2023-02-27 00:00:00 GMT-5',
to: 'Thu | 2023-03-02 00:00:00 GMT-5',
},
{
fr: 'Mon | 2023-03-06 00:00:00 GMT-5',
to: 'Thu | 2023-03-09 00:00:00 GMT-5',
},
{
fr: 'Mon | 2023-03-13 00:00:00 GMT-5',
to: 'Thu | 2023-03-16 00:00:00 GMT-5',
},
{
fr: 'Mon | 2023-03-20 00:00:00 GMT-5',
to: 'Thu | 2023-03-23 00:00:00 GMT-5',
},
{
fr: 'Mon | 2023-03-27 00:00:00 GMT-5',
to: 'Thu | 2023-03-30 00:00:00 GMT-5',
},
]);
});
it('returns regions with days/times range', () => {
const dashboardTz = 'America/Chicago';
const regionsTz = dashboardTz;
const cfg: TimeRegionConfig = {
timezone: regionsTz,
fromDayOfWeek: 1,
from: '20:00',
toDayOfWeek: 2,
@@ -129,8 +253,8 @@ describe('timeRegions', () => {
};
const tr: TimeRange = {
from: dateTime('2023-03-01'),
to: dateTime('2023-03-31'),
from: dateTimeForTimeZone(dashboardTz, '2023-03-01'),
to: dateTimeForTimeZone(dashboardTz, '2023-03-31'),
raw: {
to: '',
from: '',
@@ -138,26 +262,119 @@ describe('timeRegions', () => {
};
const regions = calculateTimesWithin(cfg, tr);
expect(regions).toMatchInlineSnapshot(`
[
{
"from": 1678132800000,
"to": 1678183200000,
},
{
"from": 1678737600000,
"to": 1678788000000,
},
{
"from": 1679342400000,
"to": 1679392800000,
},
{
"from": 1679947200000,
"to": 1679997600000,
},
]
`);
const formatted = regions.map((r) => formatAbsoluteRange(r, regionsTz));
expect(formatted).toEqual([
{
fr: 'Mon | 2023-03-06 20:00:00 GMT-6',
to: 'Tue | 2023-03-07 10:00:00 GMT-6',
},
{
fr: 'Mon | 2023-03-13 20:00:00 GMT-5',
to: 'Tue | 2023-03-14 10:00:00 GMT-5',
},
{
fr: 'Mon | 2023-03-20 20:00:00 GMT-5',
to: 'Tue | 2023-03-21 10:00:00 GMT-5',
},
{
fr: 'Mon | 2023-03-27 20:00:00 GMT-5',
to: 'Tue | 2023-03-28 10:00:00 GMT-5',
},
]);
});
});
type TestDef = [
name: string,
fromDayOfWeek: number | null,
from: string | null,
toDayOfWeek: number | null,
to: string | null,
cronExpr: string,
duration: string,
];
let _ = null;
describe('various scenarios (regions)', () => {
/* eslint-disable */
// prettier-ignore
let tests: TestDef[] = [
['from every day (time before) to every day (time after)', _, '10:27', _, '14:27', '27 10 * * *', '4h'],
['from every day (time after) to every day (time before)', _, '22:27', _, '02:27', '27 22 * * *', '4h'],
['from every day (time) to every day (no time)', _, '10:27', _, _, '27 10 * * *', ''],
['from fri (no time)', 5, _, _, _, '0 0 * * 5', '1d'],
['from fri (no time) to tues (no time)', 5, _, 2, _, '0 0 * * 5', '5d'],
['from fri (no time) to tues (time)', 5, _, 2, '02:27', '0 0 * * 5', '4d 2h 27m'],
['from fri (time) to tues (no time)', 5, '10:27', 2, _, '27 10 * * 5', '4d'],
['from fri (time) to tues (time)', 5, '10:27', 2, '14:27', '27 10 * * 5', '4d 4h'],
// same day
['from fri (time before) to fri (time after)', 5, '10:27', 5, '14:27', '27 10 * * 5', '4h'],
// "toDay" should assume Fri
['from fri (time before) to every day (time after)', 5, '10:27', _, '14:27', '27 10 * * 5', '4h'],
// wrap-around case
['from fri (time after) to fri (time before)', 5, '14:27', 5, '10:27', '27 14 * * 5', '6d 20h'],
];
/* eslint-enable */
tests.forEach(([name, fromDayOfWeek, from, toDayOfWeek, to, cronExpr, duration]) => {
it(name, () => {
const cron = convertToCron(fromDayOfWeek, from, toDayOfWeek, to);
expect(cron).not.toBeUndefined();
expect(cron?.cronExpr).toEqual(cronExpr);
expect(reverseParseDuration(durationFromSeconds(cron?.duration ?? 0), false)).toEqual(duration);
});
});
});
describe('various scenarios (points)', () => {
/* eslint-disable */
// prettier-ignore
let tests: TestDef[] = [
['from every day (time)', _, '10:03', _, _, '3 10 * * *', ''],
['from every day (time) to every day (time)', _, '10:03', _, '10:03', '3 10 * * *', ''],
['from tues (time)', 2, '10:03', _, _, '3 10 * * 2', ''],
['from tues (time) to tues (time)', 2, '10:03', _, '10:03', '3 10 * * 2', ''],
];
/* eslint-enable */
tests.forEach(([name, fromDayOfWeek, from, toDayOfWeek, to, cronExpr, duration]) => {
it(name, () => {
const cron = convertToCron(fromDayOfWeek, from, toDayOfWeek, to);
expect(cron).not.toBeUndefined();
expect(cron?.cronExpr).toEqual(cronExpr);
expect(reverseParseDuration(durationFromSeconds(cron?.duration ?? 0), false)).toEqual(duration);
});
});
});
describe('convert simple time region config to cron string and duration', () => {
it.each`
from | fromDOW | to | toDOW | timezone | expectedCron | expectedDuration
${'03:03'} | ${1} | ${'03:03'} | ${2} | ${'browser'} | ${'3 3 * * 1'} | ${'1d'}
${'03:03'} | ${7} | ${'03:03'} | ${1} | ${'browser'} | ${'3 3 * * 7'} | ${'1d'}
${'09:03'} | ${7} | ${'03:03'} | ${1} | ${'browser'} | ${'3 9 * * 7'} | ${'18h'}
${'03:03'} | ${7} | ${'04:03'} | ${7} | ${'browser'} | ${'3 3 * * 7'} | ${'1h'}
${'03:03'} | ${7} | ${'02:03'} | ${7} | ${'browser'} | ${'3 3 * * 7'} | ${'6d 23h'}
${'03:03'} | ${7} | ${'3:03'} | ${7} | ${'browser'} | ${'3 3 * * 7'} | ${''}
`(
"time region config with from time '$from' and DOW '$fromDOW', to: '$to' and DOW '$toDOW' should generate a cron string of '$expectedCron' and '$expectedDuration'",
({ from, fromDOW, to, toDOW, timezone, expectedCron, expectedDuration }) => {
const timeConfig: TimeRegionConfig = { from, fromDayOfWeek: fromDOW, to, toDayOfWeek: toDOW, timezone };
const convertedCron = convertToCron(
timeConfig.fromDayOfWeek,
timeConfig.from,
timeConfig.toDayOfWeek,
timeConfig.to
)!;
expect(convertedCron).not.toBeUndefined();
expect(convertedCron.cronExpr).toEqual(expectedCron);
expect(reverseParseDuration(durationFromSeconds(convertedCron.duration), false)).toEqual(expectedDuration);
}
);
});
});
+139 -144
View File
@@ -1,6 +1,11 @@
import { AbsoluteTimeRange, dateTime, TimeRange } from '@grafana/data';
import { Cron } from 'croner';
import { AbsoluteTimeRange, TimeRange, durationToMilliseconds, parseDuration } from '@grafana/data';
export type TimeRegionMode = null | 'cron';
export interface TimeRegionConfig {
mode?: TimeRegionMode;
from?: string;
fromDayOfWeek?: number; // 1-7
@@ -8,165 +13,155 @@ export interface TimeRegionConfig {
toDayOfWeek?: number; // 1-7
timezone?: string;
cronExpr?: string; // 0 9 * * 1-5
duration?: string; // 8h
}
interface ParsedTime {
dayOfWeek?: number; // 1-7
h?: number; // 0-23
m?: number; // 0-59
s?: number; // 0-59
const secsInDay = 24 * 3600;
function getDurationSecs(
fromDay: number,
fromHour: number,
fromMin: number,
toDay: number,
toHour: number,
toMin: number
) {
let days = toDay - fromDay;
// account for rollover
if (days < 0) {
days += 7;
}
let fromSecs = fromHour * 3600 + fromMin * 60;
let toSecs = toHour * 3600 + toMin * 60;
let durSecs = 0;
// account for toTime < fromTime on same day
if (days === 0 && toSecs < fromSecs) {
durSecs = 7 * secsInDay - (fromSecs - toSecs);
} else {
let daysSecs = days * secsInDay;
durSecs = daysSecs - fromSecs + toSecs;
}
return durSecs;
}
export function convertToCron(
fromDay?: number | null,
from?: string | null,
toDay?: number | null,
to?: string | null
) {
// valid defs must have a "from"
if (fromDay == null && from == null) {
return undefined;
}
const cronCfg = {
cronExpr: '',
duration: 0,
};
const isEveryDay = fromDay == null && toDay == null;
// if the def contains only days of week, then they become end-day-inclusive
const toDayEnd = fromDay != null && from == null && to == null;
from ??= '00:00';
// 1. create cron (only requires froms)
let [fromHour, fromMin] = from.split(':').map((v) => +v);
cronCfg.cronExpr = `${fromMin} ${fromHour} * * ${fromDay ?? '*'}`;
// 2. determine duration
fromDay ??= 1;
toDay ??= fromDay;
// e.g. from Wed to Fri (implies inclusive Fri)
if (toDayEnd) {
to = '00:00';
toDay += toDay === 7 ? -6 : 1;
}
to ??= from;
let [toHour, toMin] = to.split(':').map((v) => +v);
let fromSecs = fromHour * 3600 + fromMin * 60;
let toSecs = toHour * 3600 + toMin * 60;
// e.g. every day from 22:00 to 02:00 (implied next day)
// NOTE: the odd wrap-around case of toSecs < fromSecs in same day is handled inside getDurationSecs()
if (isEveryDay && toSecs < fromSecs) {
toDay += toDay === 7 ? -6 : 1;
}
cronCfg.duration = getDurationSecs(fromDay, fromHour, fromMin, toDay, toHour, toMin);
return cronCfg;
}
export function calculateTimesWithin(cfg: TimeRegionConfig, tRange: TimeRange): AbsoluteTimeRange[] {
if (!(cfg.fromDayOfWeek || cfg.from) && !(cfg.toDayOfWeek || cfg.to)) {
const ranges: AbsoluteTimeRange[] = [];
let cronExpr = '';
let durationMs = 0;
let { fromDayOfWeek, from, toDayOfWeek, to, duration = '' } = cfg;
if (cfg.mode === 'cron') {
cronExpr = cfg.cronExpr ?? '';
durationMs = durationToMilliseconds(parseDuration(duration));
} else {
// remove empty strings
from = from === '' ? undefined : from;
to = to === '' ? undefined : to;
const cron = convertToCron(fromDayOfWeek, from, toDayOfWeek, to);
if (cron != null) {
cronExpr = cron.cronExpr;
durationMs = cron.duration * 1e3;
}
}
if (cronExpr === '') {
return [];
}
// So we can mutate
const timeRegion = { ...cfg };
try {
let tz = cfg.timezone === 'browser' ? undefined : cfg.timezone === 'utc' ? 'Etc/UTC' : cfg.timezone;
if (timeRegion.from && !timeRegion.to) {
timeRegion.to = timeRegion.from;
}
let job = new Cron(cronExpr, { timezone: tz });
if (!timeRegion.from && timeRegion.to) {
timeRegion.from = timeRegion.to;
}
// get previous run that may overlap with start of timerange
let fromDate: Date | null = new Date(tRange.from.valueOf() - durationMs);
const hRange = {
from: parseTimeOfDay(timeRegion.from),
to: parseTimeOfDay(timeRegion.to),
};
let toMs = tRange.to.valueOf();
let nextDate = job.nextRun(fromDate);
if (!timeRegion.fromDayOfWeek && timeRegion.toDayOfWeek) {
timeRegion.fromDayOfWeek = timeRegion.toDayOfWeek;
}
while (nextDate != null) {
let nextMs = +nextDate;
if (!timeRegion.toDayOfWeek && timeRegion.fromDayOfWeek) {
timeRegion.toDayOfWeek = timeRegion.fromDayOfWeek;
}
if (timeRegion.fromDayOfWeek) {
hRange.from.dayOfWeek = Number(timeRegion.fromDayOfWeek);
}
if (timeRegion.toDayOfWeek) {
hRange.to.dayOfWeek = Number(timeRegion.toDayOfWeek);
}
if (hRange.from.dayOfWeek && hRange.from.h == null && hRange.from.m == null) {
hRange.from.h = 0;
hRange.from.m = 0;
hRange.from.s = 0;
}
if (hRange.to.dayOfWeek && hRange.to.h == null && hRange.to.m == null) {
hRange.to.h = 23;
hRange.to.m = 59;
hRange.to.s = 59;
}
if (!hRange.from || !hRange.to) {
return [];
}
if (hRange.from.h == null) {
hRange.from.h = 0;
}
if (hRange.to.h == null) {
hRange.to.h = 23;
}
const regions: AbsoluteTimeRange[] = [];
const fromStart = dateTime(tRange.from).utc();
fromStart.set('hour', 0);
fromStart.set('minute', 0);
fromStart.set('second', 0);
fromStart.set('millisecond', 0);
fromStart.add(hRange.from.h, 'hours');
fromStart.add(hRange.from.m, 'minutes');
fromStart.add(hRange.from.s, 'seconds');
while (fromStart.unix() <= tRange.to.unix()) {
while (hRange.from.dayOfWeek && hRange.from.dayOfWeek !== fromStart.isoWeekday()) {
fromStart.add(24, 'hours');
}
if (fromStart.unix() > tRange.to.unix()) {
break;
}
const fromEnd = dateTime(fromStart).utc();
if (fromEnd.hour) {
if (hRange.from.h <= hRange.to.h) {
fromEnd.add(hRange.to.h - hRange.from.h, 'hours');
} else if (hRange.from.h > hRange.to.h) {
while (fromEnd.hour() !== hRange.to.h) {
fromEnd.add(1, 'hours');
}
if (nextMs > toMs) {
break;
} else {
fromEnd.add(24 - hRange.from.h, 'hours');
while (fromEnd.hour() !== hRange.to.h) {
fromEnd.add(1, 'hours');
}
ranges.push({
from: nextMs,
to: nextMs + durationMs,
});
nextDate = job.nextRun(nextDate);
}
}
fromEnd.set('minute', hRange.to.m ?? 0);
fromEnd.set('second', hRange.to.s ?? 0);
while (hRange.to.dayOfWeek && hRange.to.dayOfWeek !== fromEnd.isoWeekday()) {
fromEnd.add(24, 'hours');
}
const outsideRange =
(fromStart.unix() < tRange.from.unix() && fromEnd.unix() < tRange.from.unix()) ||
(fromStart.unix() > tRange.to.unix() && fromEnd.unix() > tRange.to.unix());
if (!outsideRange) {
regions.push({ from: fromStart.valueOf(), to: fromEnd.valueOf() });
}
fromStart.add(24, 'hours');
} catch (e) {
// invalid expression
console.error(e);
}
return regions;
}
export function parseTimeOfDay(str?: string): ParsedTime {
const result: ParsedTime = {};
if (!str?.length) {
return result;
}
const match = str.split(':');
if (!match?.length) {
return result;
}
result.h = Math.min(23, Math.max(0, Number(match[0])));
if (match.length > 1) {
result.m = Math.min(60, Math.max(0, Number(match[1])));
if (match.length > 2) {
result.s = Math.min(60, Math.max(0, Number(match[2])));
}
}
return result;
}
export function formatTimeOfDayString(t?: ParsedTime): string {
if (!t || (t.h == null && t.m == null && t.s == null)) {
return '';
}
let str = String(t.h ?? 0).padStart(2, '0') + ':' + String(t.m ?? 0).padStart(2, '0');
if (t.s != null) {
str += String(t.s ?? 0).padStart(2, '0');
}
return str;
return ranges;
}
@@ -1,12 +1,24 @@
import { css } from '@emotion/css';
import moment, { Moment } from 'moment/moment';
import { useState } from 'react';
import { ChangeEvent, useState } from 'react';
import { dateTimeAsMoment, getTimeZoneInfo, GrafanaTheme2, isDateTime, SelectableValue } from '@grafana/data';
import { Button, Field, FieldSet, Select, Stack, TimeOfDayPicker, TimeZonePicker, useStyles2 } from '@grafana/ui';
import {
Button,
Field,
FieldSet,
Input,
Select,
Stack,
Switch,
TimeOfDayPicker,
TimeZonePicker,
useStyles2,
} from '@grafana/ui';
import { TimeZoneOffset } from '@grafana/ui/src/components/DateTimePickers/TimeZonePicker/TimeZoneOffset';
import { TimeZoneTitle } from '@grafana/ui/src/components/DateTimePickers/TimeZonePicker/TimeZoneTitle';
import { TimeRegionConfig } from 'app/core/utils/timeRegions';
import { t } from 'app/core/internationalization';
import { TimeRegionConfig, TimeRegionMode } from 'app/core/utils/timeRegions';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
interface Props {
@@ -20,6 +32,7 @@ const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'
value: idx + 1,
};
});
export const TimeRegionEditor = ({ value, onChange }: Props) => {
const styles = useStyles2(getStyles);
const timestamp = Date.now();
@@ -85,6 +98,18 @@ export const TimeRegionEditor = ({ value, onChange }: Props) => {
onChange({ ...value, timezone: v });
};
const onModeChange = (v: TimeRegionMode) => {
onChange({ ...value, mode: v });
};
const onCronExprChange = (v: string) => {
onChange({ ...value, cronExpr: v });
};
const onDurationChange = (v: string) => {
onChange({ ...value, duration: v });
};
const onFromDayOfWeekChange = (v: SelectableValue<number>) => {
const fromDayOfWeek = v ? v.value : undefined;
const toDayOfWeek = v ? value.toDayOfWeek : undefined; // clear if everyday
@@ -125,46 +150,92 @@ export const TimeRegionEditor = ({ value, onChange }: Props) => {
return (
<FieldSet className={styles.wrapper}>
<Field label="From">
<Stack gap={0.5}>
<Select
options={days}
isClearable
placeholder="Everyday"
value={value.fromDayOfWeek ?? null}
onChange={(v) => onFromDayOfWeekChange(v)}
width={20}
/>
<TimeOfDayPicker
value={isDateTime(from) ? from : undefined}
onChange={(v) => onTimeChange(v ? dateTimeAsMoment(v) : v, 'from')}
allowEmpty={true}
placeholder="HH:mm"
size="sm"
/>
</Stack>
<Field
label={t('dashboard-settings.time-regions.advanced-label', 'Advanced')}
description={
<>
{t('dashboard-settings.time-regions.advanced-description-use', 'Use ')}
<a href="https://crontab.run/" target="_blank">
{t('dashboard-settings.time-regions.advanced-description-cron', 'Cron syntax')}
</a>
{t(
'dashboard-settings.time-regions.advanced-description-rest',
' to define a recurrence schedule and duration'
)}
</>
}
>
<Switch
id="time-regions-adanced-mode-toggle"
value={value.mode === 'cron'}
onChange={(e: ChangeEvent<HTMLInputElement>) => onModeChange(e.currentTarget.checked ? 'cron' : null)}
/>
</Field>
<Field label="To">
<Stack gap={0.5}>
{(value.fromDayOfWeek || value.toDayOfWeek) && (
<Select
options={days}
isClearable
placeholder={getToPlaceholder()}
value={value.toDayOfWeek ?? null}
onChange={(v) => onToDayOfWeekChange(v)}
width={20}
{value.mode == null && (
<>
<Field label="From">
<Stack gap={0.5}>
<Select
options={days}
isClearable
placeholder="Everyday"
value={value.fromDayOfWeek ?? null}
onChange={(v) => onFromDayOfWeekChange(v)}
width={20}
/>
<TimeOfDayPicker
value={isDateTime(from) ? from : undefined}
onChange={(v) => onTimeChange(v ? dateTimeAsMoment(v) : v, 'from')}
allowEmpty={true}
placeholder="HH:mm"
size="sm"
/>
</Stack>
</Field>
<Field label="To">
<Stack gap={0.5}>
{(value.fromDayOfWeek || value.toDayOfWeek) && (
<Select
options={days}
isClearable
placeholder={getToPlaceholder()}
value={value.toDayOfWeek ?? null}
onChange={(v) => onToDayOfWeekChange(v)}
width={20}
/>
)}
<TimeOfDayPicker
value={isDateTime(to) ? to : undefined}
onChange={(v) => onTimeChange(v ? dateTimeAsMoment(v) : v, 'to')}
allowEmpty={true}
placeholder="HH:mm"
size="sm"
/>
</Stack>
</Field>
</>
)}
{value.mode === 'cron' && (
<>
<Field label="Cron expression">
<Input
onChange={(e: ChangeEvent<HTMLInputElement>) => onCronExprChange(e.target.value)}
value={value.cronExpr}
placeholder="0 9 * * 1-5"
width={40}
/>
)}
<TimeOfDayPicker
value={isDateTime(to) ? to : undefined}
onChange={(v) => onTimeChange(v ? dateTimeAsMoment(v) : v, 'to')}
allowEmpty={true}
placeholder="HH:mm"
size="sm"
/>
</Stack>
</Field>
</Field>
<Field label="Duration">
<Input
onChange={(e: ChangeEvent<HTMLInputElement>) => onDurationChange(e.target.value)}
value={value.duration}
placeholder="8h"
width={40}
/>
</Field>
</>
)}
<Field label="Timezone">{renderTimezone()}</Field>
</FieldSet>
);
@@ -108,7 +108,7 @@ export class GrafanaDatasource extends DataSourceWithBackend<GrafanaQuery> {
continue;
}
if (target.queryType === GrafanaQueryType.TimeRegions) {
const frame = doTimeRegionQuery('', target.timeRegion!, request.range, request.timezone);
const frame = doTimeRegionQuery('', target.timeRegion!, request.range);
results.push(
of({
data: frame ? [frame] : [],
@@ -195,12 +195,7 @@ export class GrafanaDatasource extends DataSourceWithBackend<GrafanaQuery> {
async getAnnotations(options: AnnotationQueryRequest<GrafanaQuery>): Promise<DataQueryResponse> {
const query = options.annotation.target as GrafanaQuery;
if (query?.queryType === GrafanaQueryType.TimeRegions) {
const frame = doTimeRegionQuery(
options.annotation.name,
query.timeRegion!,
options.range,
getDashboardSrv().getCurrent()?.timezone // Annotation queries don't include the timezone
);
const frame = doTimeRegionQuery(options.annotation.name, query.timeRegion!, options.range);
return Promise.resolve({ data: frame ? [frame] : [] });
}
@@ -6,7 +6,7 @@ describe('grafana data source', () => {
it('supports time region query', () => {
const frame = doTimeRegionQuery(
'test',
{ fromDayOfWeek: 1, toDayOfWeek: 2 },
{ fromDayOfWeek: 1, toDayOfWeek: 2, timezone: 'utc' },
{
from: dateTime('2023-03-01'),
to: dateTime('2023-03-31'),
@@ -14,8 +14,7 @@ describe('grafana data source', () => {
to: '',
from: '',
},
},
'utc'
}
);
expect(toDataFrameDTO(frame!)).toMatchInlineSnapshot(`
@@ -39,10 +38,10 @@ describe('grafana data source', () => {
"name": "timeEnd",
"type": "time",
"values": [
1678233599000,
1678838399000,
1679443199000,
1680047999000,
1678233600000,
1678838400000,
1679443200000,
1680048000000,
],
},
{
@@ -80,8 +79,7 @@ describe('grafana data source', () => {
to: '',
from: '',
},
},
'utc'
}
);
expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(`
@@ -101,7 +99,7 @@ describe('grafana data source', () => {
"name": "timeEnd",
"type": "time",
"values": [
1678147199000,
1678147200000,
],
},
{
@@ -133,8 +131,7 @@ describe('grafana data source', () => {
to: '',
from: '',
},
},
'utc'
}
);
expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(`
@@ -154,7 +151,7 @@ describe('grafana data source', () => {
"name": "timeEnd",
"type": "time",
"values": [
1678165199000,
1678165200000,
],
},
{
@@ -186,8 +183,7 @@ describe('grafana data source', () => {
to: '',
from: '',
},
},
'utc'
}
);
expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(`
@@ -207,7 +203,7 @@ describe('grafana data source', () => {
"name": "timeEnd",
"type": "time",
"values": [
1678168799000,
1678168800000,
],
},
{
@@ -239,8 +235,7 @@ describe('grafana data source', () => {
to: '',
from: '',
},
},
'utc'
}
);
expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(`
@@ -260,7 +255,7 @@ describe('grafana data source', () => {
"name": "timeEnd",
"type": "time",
"values": [
1678143599000,
1678143600000,
],
},
{
@@ -292,8 +287,7 @@ describe('grafana data source', () => {
to: '',
from: '',
},
},
'utc'
}
);
expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(`
@@ -313,7 +307,7 @@ describe('grafana data source', () => {
"name": "timeEnd",
"type": "time",
"values": [
1678121999000,
1678122000000,
],
},
{
@@ -345,8 +339,7 @@ describe('grafana data source', () => {
to: '',
from: '',
},
},
'Asia/Dubai'
}
);
expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(`
@@ -366,7 +359,7 @@ describe('grafana data source', () => {
"name": "timeEnd",
"type": "time",
"values": [
1678147199000,
1678147200000,
],
},
{
@@ -398,8 +391,7 @@ describe('grafana data source', () => {
to: '',
from: '',
},
},
'America/Chicago'
}
);
expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(`
@@ -451,8 +443,7 @@ describe('grafana data source', () => {
to: '',
from: '',
},
},
'America/Chicago'
}
);
expect(toDataFrameDTO(frame!).fields).toMatchInlineSnapshot(`
@@ -1,48 +1,28 @@
import { TimeRange, DataFrame, FieldType, getTimeZoneInfo } from '@grafana/data';
import { TimeRange, DataFrame, FieldType } from '@grafana/data';
import { TimeRegionConfig, calculateTimesWithin } from 'app/core/utils/timeRegions';
export function doTimeRegionQuery(
name: string,
config: TimeRegionConfig,
range: TimeRange,
tz: string
): DataFrame | undefined {
if (!config) {
return undefined;
}
const regions = calculateTimesWithin(config, range); // UTC
if (!regions.length) {
return undefined;
}
export function doTimeRegionQuery(name: string, config: TimeRegionConfig, range: TimeRange): DataFrame | undefined {
const { mode, duration, cronExpr, from, fromDayOfWeek } = config;
const times: number[] = [];
const timesEnd: number[] = [];
const texts: string[] = [];
const isValidSimple = mode == null && (fromDayOfWeek != null || from != null);
const isValidAdvanced = mode === 'cron' && cronExpr != null && duration != null;
const regionTimezone = config.timezone ?? tz;
if (isValidSimple || isValidAdvanced) {
const ranges = calculateTimesWithin(config, range);
for (const region of regions) {
let from = region.from;
let to = region.to;
if (ranges.length > 0) {
const frame: DataFrame = {
fields: [
{ name: 'time', type: FieldType.time, values: ranges.map((r) => r.from), config: {} },
{ name: 'timeEnd', type: FieldType.time, values: ranges.map((r) => r.to), config: {} },
{ name: 'text', type: FieldType.string, values: Array(ranges.length).fill(name), config: {} },
],
length: ranges.length,
};
const info = getTimeZoneInfo(regionTimezone, from);
if (info) {
const offset = info.offsetInMins * 60 * 1000;
from += offset;
to += offset;
return frame;
}
times.push(from);
timesEnd.push(to);
texts.push(name);
}
return {
fields: [
{ name: 'time', type: FieldType.time, values: times, config: {} },
{ name: 'timeEnd', type: FieldType.time, values: timesEnd, config: {} },
{ name: 'text', type: FieldType.string, values: texts, config: {} },
],
length: times.length,
};
return undefined;
}
@@ -1,372 +0,0 @@
import { dateTime } from '@grafana/data';
import { TimeRegionManager, colorModes } from '../time_region_manager';
describe('TimeRegionManager', () => {
function plotOptionsScenario(desc: string, func: any) {
describe(desc, () => {
const ctx: any = {
panel: {
timeRegions: [],
},
options: {
grid: { markings: [] },
},
panelCtrl: {
range: {},
dashboard: {},
},
};
ctx.setup = (regions: any, from: any, to: any) => {
ctx.panel.timeRegions = regions;
ctx.panelCtrl.range.from = from;
ctx.panelCtrl.range.to = to;
const manager = new TimeRegionManager(ctx.panelCtrl);
manager.addFlotOptions(ctx.options, ctx.panel);
};
ctx.printScenario = () => {
console.log(
`Time range: from=${ctx.panelCtrl.range.from.format()}, to=${ctx.panelCtrl.range.to.format()}`,
ctx.panelCtrl.range.from._isUTC
);
ctx.options.grid.markings.forEach((m: any, i: number) => {
console.log(
`Marking (${i}): from=${dateTime(m.xaxis.from).format()}, to=${dateTime(m.xaxis.to).format()}, color=${
m.color
}`
);
});
};
func(ctx);
});
}
describe('When colors missing in config', () => {
plotOptionsScenario('should not throw an error when fillColor is undefined', (ctx: any) => {
const regions = [
{ fromDayOfWeek: 1, toDayOfWeek: 1, fill: true, line: true, lineColor: '#ffffff', colorMode: 'custom' },
];
const from = dateTime('2018-01-01T00:00:00+01:00');
const to = dateTime('2018-01-01T23:59:00+01:00');
expect(() => ctx.setup(regions, from, to)).not.toThrow();
});
plotOptionsScenario('should not throw an error when lineColor is undefined', (ctx: any) => {
const regions = [
{ fromDayOfWeek: 1, toDayOfWeek: 1, fill: true, fillColor: '#ffffff', line: true, colorMode: 'custom' },
];
const from = dateTime('2018-01-01T00:00:00+01:00');
const to = dateTime('2018-01-01T23:59:00+01:00');
expect(() => ctx.setup(regions, from, to)).not.toThrow();
});
});
describe('When creating plot markings using local time', () => {
plotOptionsScenario('for day of week region', (ctx: any) => {
const regions = [{ fromDayOfWeek: 1, toDayOfWeek: 1, fill: true, line: true, colorMode: 'red' }];
const from = dateTime('2018-01-01T00:00:00+01:00');
const to = dateTime('2018-01-01T23:59:00+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add fill', () => {
const markings = ctx.options.grid.markings;
expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-01-01T01:00:00+01:00').format());
expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-01-02T00:59:59+01:00').format());
expect(markings[0].color).toBe(colorModes.red.color.fill);
});
it('should add line before', () => {
const markings = ctx.options.grid.markings;
expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-01-01T01:00:00+01:00').format());
expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-01-01T01:00:00+01:00').format());
expect(markings[1].color).toBe(colorModes.red.color.line);
});
it('should add line after', () => {
const markings = ctx.options.grid.markings;
expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-01-02T00:59:59+01:00').format());
expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-01-02T00:59:59+01:00').format());
expect(markings[2].color).toBe(colorModes.red.color.line);
});
});
plotOptionsScenario('for time from region', (ctx: any) => {
const regions = [{ from: '05:00', fill: true, colorMode: 'red' }];
const from = dateTime('2018-01-01T00:00+01:00');
const to = dateTime('2018-01-03T23:59+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill at 05:00 each day', () => {
const markings = ctx.options.grid.markings;
expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-01-01T06:00:00+01:00').format());
expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-01-01T06:00:00+01:00').format());
expect(markings[0].color).toBe(colorModes.red.color.fill);
expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-01-02T06:00:00+01:00').format());
expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-01-02T06:00:00+01:00').format());
expect(markings[1].color).toBe(colorModes.red.color.fill);
expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-01-03T06:00:00+01:00').format());
expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-01-03T06:00:00+01:00').format());
expect(markings[2].color).toBe(colorModes.red.color.fill);
});
});
plotOptionsScenario('for time to region', (ctx: any) => {
const regions = [{ to: '05:00', fill: true, colorMode: 'red' }];
const from = dateTime('2018-02-01T00:00+01:00');
const to = dateTime('2018-02-03T23:59+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill at 05:00 each day', () => {
const markings = ctx.options.grid.markings;
expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-02-01T06:00:00+01:00').format());
expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-02-01T06:00:00+01:00').format());
expect(markings[0].color).toBe(colorModes.red.color.fill);
expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-02-02T06:00:00+01:00').format());
expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-02-02T06:00:00+01:00').format());
expect(markings[1].color).toBe(colorModes.red.color.fill);
expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-02-03T06:00:00+01:00').format());
expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-02-03T06:00:00+01:00').format());
expect(markings[2].color).toBe(colorModes.red.color.fill);
});
});
plotOptionsScenario('for time from/to region', (ctx: any) => {
const regions = [{ from: '00:00', to: '05:00', fill: true, colorMode: 'red' }];
const from = dateTime('2018-12-01T00:00+01:00');
const to = dateTime('2018-12-03T23:59+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill between 00:00 and 05:00 each day', () => {
const markings = ctx.options.grid.markings;
expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-12-01T01:00:00+01:00').format());
expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-12-01T06:00:00+01:00').format());
expect(markings[0].color).toBe(colorModes.red.color.fill);
expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-12-02T01:00:00+01:00').format());
expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-12-02T06:00:00+01:00').format());
expect(markings[1].color).toBe(colorModes.red.color.fill);
expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-12-03T01:00:00+01:00').format());
expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-12-03T06:00:00+01:00').format());
expect(markings[2].color).toBe(colorModes.red.color.fill);
});
});
plotOptionsScenario('for time from/to region crossing midnight', (ctx: any) => {
const regions = [{ from: '22:00', to: '00:30', fill: true, colorMode: 'red' }];
const from = dateTime('2018-12-01T12:00+01:00');
const to = dateTime('2018-12-04T08:00+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill between 22:00 and 00:30 each day', () => {
const markings = ctx.options.grid.markings;
expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-12-01T23:00:00+01:00').format());
expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-12-02T01:30:00+01:00').format());
expect(markings[0].color).toBe(colorModes.red.color.fill);
expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-12-02T23:00:00+01:00').format());
expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-12-03T01:30:00+01:00').format());
expect(markings[1].color).toBe(colorModes.red.color.fill);
expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-12-03T23:00:00+01:00').format());
expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-12-04T01:30:00+01:00').format());
expect(markings[2].color).toBe(colorModes.red.color.fill);
});
});
plotOptionsScenario('for day of week from/to region', (ctx: any) => {
const regions = [{ fromDayOfWeek: 7, toDayOfWeek: 7, fill: true, colorMode: 'red' }];
const from = dateTime('2018-01-01T18:45:05+01:00');
const to = dateTime('2018-01-22T08:27:00+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill at each sunday', () => {
const markings = ctx.options.grid.markings;
expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-01-07T01:00:00+01:00').format());
expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-01-08T00:59:59+01:00').format());
expect(markings[0].color).toBe(colorModes.red.color.fill);
expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-01-14T01:00:00+01:00').format());
expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-01-15T00:59:59+01:00').format());
expect(markings[1].color).toBe(colorModes.red.color.fill);
expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-01-21T01:00:00+01:00').format());
expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-01-22T00:59:59+01:00').format());
expect(markings[2].color).toBe(colorModes.red.color.fill);
});
});
plotOptionsScenario('for day of week from region', (ctx: any) => {
const regions = [{ fromDayOfWeek: 7, fill: true, colorMode: 'red' }];
const from = dateTime('2018-01-01T18:45:05+01:00');
const to = dateTime('2018-01-22T08:27:00+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill at each sunday', () => {
const markings = ctx.options.grid.markings;
expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-01-07T01:00:00+01:00').format());
expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-01-08T00:59:59+01:00').format());
expect(markings[0].color).toBe(colorModes.red.color.fill);
expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-01-14T01:00:00+01:00').format());
expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-01-15T00:59:59+01:00').format());
expect(markings[1].color).toBe(colorModes.red.color.fill);
expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-01-21T01:00:00+01:00').format());
expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-01-22T00:59:59+01:00').format());
expect(markings[2].color).toBe(colorModes.red.color.fill);
});
});
plotOptionsScenario('for day of week to region', (ctx: any) => {
const regions = [{ toDayOfWeek: 7, fill: true, colorMode: 'red' }];
const from = dateTime('2018-01-01T18:45:05+01:00');
const to = dateTime('2018-01-22T08:27:00+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill at each sunday', () => {
const markings = ctx.options.grid.markings;
expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-01-07T01:00:00+01:00').format());
expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-01-08T00:59:59+01:00').format());
expect(markings[0].color).toBe(colorModes.red.color.fill);
expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-01-14T01:00:00+01:00').format());
expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-01-15T00:59:59+01:00').format());
expect(markings[1].color).toBe(colorModes.red.color.fill);
expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-01-21T01:00:00+01:00').format());
expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-01-22T00:59:59+01:00').format());
expect(markings[2].color).toBe(colorModes.red.color.fill);
});
});
plotOptionsScenario('for day of week from/to time region', (ctx: any) => {
const regions = [{ fromDayOfWeek: 7, from: '23:00', toDayOfWeek: 1, to: '01:40', fill: true, colorMode: 'red' }];
const from = dateTime('2018-12-07T12:51:19+01:00');
const to = dateTime('2018-12-10T13:51:29+01:00');
ctx.setup(regions, from, to);
it('should add 1 marking', () => {
expect(ctx.options.grid.markings.length).toBe(1);
});
it('should add one fill between sunday 23:00 and monday 01:40', () => {
const markings = ctx.options.grid.markings;
expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-12-10T00:00:00+01:00').format());
expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-12-10T02:40:00+01:00').format());
});
});
plotOptionsScenario('for day of week from/to time region', (ctx: any) => {
const regions = [{ fromDayOfWeek: 6, from: '03:00', toDayOfWeek: 7, to: '02:00', fill: true, colorMode: 'red' }];
const from = dateTime('2018-12-07T12:51:19+01:00');
const to = dateTime('2018-12-10T13:51:29+01:00');
ctx.setup(regions, from, to);
it('should add 1 marking', () => {
expect(ctx.options.grid.markings.length).toBe(1);
});
it('should add one fill between saturday 03:00 and sunday 02:00', () => {
const markings = ctx.options.grid.markings;
expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-12-08T04:00:00+01:00').format());
expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-12-09T03:00:00+01:00').format());
});
});
plotOptionsScenario('for day of week from/to time region with daylight saving time', (ctx: any) => {
const regions = [{ fromDayOfWeek: 7, from: '20:00', toDayOfWeek: 7, to: '23:00', fill: true, colorMode: 'red' }];
const from = dateTime('2018-03-17T06:00:00+01:00');
const to = dateTime('2018-04-03T06:00:00+02:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill at each sunday between 20:00 and 23:00', () => {
const markings = ctx.options.grid.markings;
expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-03-18T21:00:00+01:00').format());
expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-03-19T00:00:00+01:00').format());
expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-03-25T22:00:00+02:00').format());
expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-03-26T01:00:00+02:00').format());
expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-04-01T22:00:00+02:00').format());
expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-04-02T01:00:00+02:00').format());
});
});
plotOptionsScenario('for each day of week with winter time', (ctx: any) => {
const regions = [{ fromDayOfWeek: 7, toDayOfWeek: 7, fill: true, colorMode: 'red' }];
const from = dateTime('2018-10-20T14:50:11+02:00');
const to = dateTime('2018-11-07T12:56:23+01:00');
ctx.setup(regions, from, to);
it('should add 3 markings', () => {
expect(ctx.options.grid.markings.length).toBe(3);
});
it('should add one fill at each sunday', () => {
const markings = ctx.options.grid.markings;
expect(dateTime(markings[0].xaxis.from).format()).toBe(dateTime('2018-10-21T02:00:00+02:00').format());
expect(dateTime(markings[0].xaxis.to).format()).toBe(dateTime('2018-10-22T01:59:59+02:00').format());
expect(dateTime(markings[1].xaxis.from).format()).toBe(dateTime('2018-10-28T02:00:00+02:00').format());
expect(dateTime(markings[1].xaxis.to).format()).toBe(dateTime('2018-10-29T00:59:59+01:00').format());
expect(dateTime(markings[2].xaxis.from).format()).toBe(dateTime('2018-11-04T01:00:00+01:00').format());
expect(dateTime(markings[2].xaxis.to).format()).toBe(dateTime('2018-11-05T00:59:59+01:00').format());
});
});
});
});
+15
View File
@@ -1337,6 +1337,12 @@
"time-zone-label": "Zeitzone",
"week-start-label": "Wochenbeginn"
},
"time-regions": {
"advanced-description-cron": "",
"advanced-description-rest": "",
"advanced-description-use": "",
"advanced-label": ""
},
"variables": {
"title": "Variable"
},
@@ -1419,6 +1425,11 @@
},
"explore": {
"add-to-dashboard": "Zum Dashboard hinzufügen",
"drilldownInfo": {
"action": "",
"description": "",
"title": ""
},
"logs": {
"logs-volume": {
"add-filters": "",
@@ -2499,6 +2510,9 @@
"detect": {
"title": "Erkennen"
},
"drilldown": {
"title": ""
},
"explore": {
"title": "Entdecken"
},
@@ -2714,6 +2728,7 @@
"close": "Menü schließen",
"dock": "Menü andocken",
"list-label": "Navigation",
"new": "",
"open": "",
"undock": "Menü abdocken"
},
+6
View File
@@ -1337,6 +1337,12 @@
"time-zone-label": "Time zone",
"week-start-label": "Week start"
},
"time-regions": {
"advanced-description-cron": "Cron syntax",
"advanced-description-rest": " to define a recurrence schedule and duration",
"advanced-description-use": "Use ",
"advanced-label": "Advanced"
},
"variables": {
"title": "Variables"
},
+15
View File
@@ -1337,6 +1337,12 @@
"time-zone-label": "Huso horario",
"week-start-label": "Inicio de la semana"
},
"time-regions": {
"advanced-description-cron": "",
"advanced-description-rest": "",
"advanced-description-use": "",
"advanced-label": ""
},
"variables": {
"title": "Variables"
},
@@ -1419,6 +1425,11 @@
},
"explore": {
"add-to-dashboard": "Añadir al tablero",
"drilldownInfo": {
"action": "",
"description": "",
"title": ""
},
"logs": {
"logs-volume": {
"add-filters": "",
@@ -2499,6 +2510,9 @@
"detect": {
"title": "Detectar"
},
"drilldown": {
"title": ""
},
"explore": {
"title": "Explorar"
},
@@ -2714,6 +2728,7 @@
"close": "Cerrar menú",
"dock": "Anclar el menú",
"list-label": "Navegación",
"new": "",
"open": "",
"undock": "Desanclar el menú"
},
+15
View File
@@ -1337,6 +1337,12 @@
"time-zone-label": "Fuseau horaire",
"week-start-label": "Début de la semaine"
},
"time-regions": {
"advanced-description-cron": "",
"advanced-description-rest": "",
"advanced-description-use": "",
"advanced-label": ""
},
"variables": {
"title": "Variables"
},
@@ -1419,6 +1425,11 @@
},
"explore": {
"add-to-dashboard": "Ajouter au tableau de bord",
"drilldownInfo": {
"action": "",
"description": "",
"title": ""
},
"logs": {
"logs-volume": {
"add-filters": "",
@@ -2499,6 +2510,9 @@
"detect": {
"title": "Détecter"
},
"drilldown": {
"title": ""
},
"explore": {
"title": "Explorer"
},
@@ -2714,6 +2728,7 @@
"close": "Fermer le menu",
"dock": "Ancrer le menu",
"list-label": "Navigation",
"new": "",
"open": "",
"undock": "Ancrer le menu"
},
@@ -1337,6 +1337,12 @@
"time-zone-label": "Ŧįmę žőʼnę",
"week-start-label": "Ŵęęĸ şŧäřŧ"
},
"time-regions": {
"advanced-description-cron": "Cřőʼn şyʼnŧäχ",
"advanced-description-rest": " ŧő đęƒįʼnę ä řęčūřřęʼnčę şčĥęđūľę äʼnđ đūřäŧįőʼn",
"advanced-description-use": "Ůşę ",
"advanced-label": "Åđväʼnčęđ"
},
"variables": {
"title": "Väřįäþľęş"
},
+15
View File
@@ -1337,6 +1337,12 @@
"time-zone-label": "Fuso horário",
"week-start-label": "Início da semana"
},
"time-regions": {
"advanced-description-cron": "",
"advanced-description-rest": "",
"advanced-description-use": "",
"advanced-label": ""
},
"variables": {
"title": "Variáveis"
},
@@ -1419,6 +1425,11 @@
},
"explore": {
"add-to-dashboard": "Adicionar ao painel de controle",
"drilldownInfo": {
"action": "",
"description": "",
"title": ""
},
"logs": {
"logs-volume": {
"add-filters": "",
@@ -2499,6 +2510,9 @@
"detect": {
"title": "Detectar"
},
"drilldown": {
"title": ""
},
"explore": {
"title": "Explorar"
},
@@ -2714,6 +2728,7 @@
"close": "Fechar menu",
"dock": "Menu da dock",
"list-label": "Navegação",
"new": "",
"open": "",
"undock": "Desacoplar menu"
},
+15
View File
@@ -1328,6 +1328,12 @@
"time-zone-label": "时区",
"week-start-label": "每周开始日"
},
"time-regions": {
"advanced-description-cron": "",
"advanced-description-rest": "",
"advanced-description-use": "",
"advanced-label": ""
},
"variables": {
"title": "变量"
},
@@ -1410,6 +1416,11 @@
},
"explore": {
"add-to-dashboard": "添加到仪表板",
"drilldownInfo": {
"action": "",
"description": "",
"title": ""
},
"logs": {
"logs-volume": {
"add-filters": "",
@@ -2489,6 +2500,9 @@
"detect": {
"title": "检测"
},
"drilldown": {
"title": ""
},
"explore": {
"title": "探索"
},
@@ -2704,6 +2718,7 @@
"close": "关闭菜单",
"dock": "停靠菜单",
"list-label": "导航",
"new": "",
"open": "",
"undock": "取消停靠菜单"
},
+8
View File
@@ -13935,6 +13935,13 @@ __metadata:
languageName: node
linkType: hard
"croner@npm:^9.0.0":
version: 9.0.0
resolution: "croner@npm:9.0.0"
checksum: 10/b3cea758eedfe92e35c4ebae46c9db615565348ad898b5938fd94ea77abc5ff68d86539db248a1666b007b0726da642c9046879e8fd598220afcc87c8135e656
languageName: node
linkType: hard
"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6":
version: 7.0.6
resolution: "cross-spawn@npm:7.0.6"
@@ -18265,6 +18272,7 @@ __metadata:
copy-webpack-plugin: "npm:12.0.2"
core-js: "npm:3.40.0"
crashme: "npm:0.0.15"
croner: "npm:^9.0.0"
css-loader: "npm:7.1.2"
css-minimizer-webpack-plugin: "npm:7.0.0"
cypress: "npm:13.10.0"