diff --git a/runtime/compilers/rillv1/parse_canvas.go b/runtime/compilers/rillv1/parse_canvas.go index c92e26f6f13..0e25cb1af5d 100644 --- a/runtime/compilers/rillv1/parse_canvas.go +++ b/runtime/compilers/rillv1/parse_canvas.go @@ -14,7 +14,7 @@ import ( ) type CanvasYAML struct { - commonYAML `yaml:",inline"` // Not accessed here, only setting it so we can use KnownFields for YAML parsing + commonYAML `yaml:",inline"` // Not accessed here, only setting it so we can use KnownFields for YAML parsing DisplayName string `yaml:"display_name"` Title string `yaml:"title"` // Deprecated: use display_name MaxWidth uint32 `yaml:"max_width"` diff --git a/runtime/compilers/rillv1/parse_explore.go b/runtime/compilers/rillv1/parse_explore.go index f1e4eeba9dd..d7138838cc7 100644 --- a/runtime/compilers/rillv1/parse_explore.go +++ b/runtime/compilers/rillv1/parse_explore.go @@ -13,7 +13,7 @@ import ( ) type ExploreYAML struct { - commonYAML `yaml:",inline"` // Not accessed here, only setting it so we can use KnownFields for YAML parsing + commonYAML `yaml:",inline"` // Not accessed here, only setting it so we can use KnownFields for YAML parsing DisplayName string `yaml:"display_name"` Title string `yaml:"title"` // Deprecated: use display_name Description string `yaml:"description"` diff --git a/runtime/compilers/rillv1/parse_metrics_view.go b/runtime/compilers/rillv1/parse_metrics_view.go index e7007aa017a..52e06a28bc0 100644 --- a/runtime/compilers/rillv1/parse_metrics_view.go +++ b/runtime/compilers/rillv1/parse_metrics_view.go @@ -17,18 +17,18 @@ import ( // MetricsViewYAML is the raw structure of a MetricsView resource defined in YAML type MetricsViewYAML struct { commonYAML `yaml:",inline"` // Not accessed here, only setting it so we can use KnownFields for YAML parsing - DisplayName string `yaml:"display_name"` - Title string `yaml:"title"` // Deprecated: use display_name - Description string `yaml:"description"` - Model string `yaml:"model"` - Database string `yaml:"database"` - DatabaseSchema string `yaml:"database_schema"` - Table string `yaml:"table"` - TimeDimension string `yaml:"timeseries"` - Watermark string `yaml:"watermark"` - SmallestTimeGrain string `yaml:"smallest_time_grain"` - FirstDayOfWeek uint32 `yaml:"first_day_of_week"` - FirstMonthOfYear uint32 `yaml:"first_month_of_year"` + DisplayName string `yaml:"display_name"` + Title string `yaml:"title"` // Deprecated: use display_name + Description string `yaml:"description"` + Model string `yaml:"model"` + Database string `yaml:"database"` + DatabaseSchema string `yaml:"database_schema"` + Table string `yaml:"table"` + TimeDimension string `yaml:"timeseries"` + Watermark string `yaml:"watermark"` + SmallestTimeGrain string `yaml:"smallest_time_grain"` + FirstDayOfWeek uint32 `yaml:"first_day_of_week"` + FirstMonthOfYear uint32 `yaml:"first_month_of_year"` Dimensions []*struct { Name string DisplayName string `yaml:"display_name"` diff --git a/runtime/metricsview/executor.go b/runtime/metricsview/executor.go index ab26de1f6f1..1a8bb6acc93 100644 --- a/runtime/metricsview/executor.go +++ b/runtime/metricsview/executor.go @@ -161,6 +161,70 @@ func (e *Executor) BindQuery(ctx context.Context, qry *Query, timestamps Timesta return e.rewriteQueryTimeRanges(ctx, qry, nil) } +// MinTime is a temporary function that fetches min time. Will be replaced with metrics_time_range resolver in a future PR. +func (e *Executor) MinTime(ctx context.Context, colName string) (time.Time, error) { + if colName == "" { + // we cannot get min time without a time dimension or a column name specified. return a 0 time + return time.Time{}, nil + } + + dialect := e.olap.Dialect() + sql := fmt.Sprintf("SELECT MIN(%s) FROM %s", dialect.EscapeIdentifier(colName), dialect.EscapeTable(e.metricsView.Database, e.metricsView.DatabaseSchema, e.metricsView.Table)) + + res, err := e.olap.Execute(ctx, &drivers.Statement{ + Query: sql, + Priority: e.priority, + ExecutionTimeout: defaultInteractiveTimeout, + }) + if err != nil { + return time.Time{}, err + } + defer res.Close() + + var t time.Time + if res.Next() { + if err := res.Scan(&t); err != nil { + return time.Time{}, fmt.Errorf("failed to scan time anchor: %w", err) + } + } + if res.Err() != nil { + return time.Time{}, fmt.Errorf("failed to scan time anchor: %w", res.Err()) + } + return t, nil +} + +// MaxTime is a temporary function that fetches max time. Will be replaced with metrics_time_range resolver in a future PR. +func (e *Executor) MaxTime(ctx context.Context, colName string) (time.Time, error) { + if colName == "" { + // we cannot get min time without a time dimension or a column name specified. return a 0 time + return time.Time{}, nil + } + + dialect := e.olap.Dialect() + sql := fmt.Sprintf("SELECT MAX(%s) FROM %s", dialect.EscapeIdentifier(colName), dialect.EscapeTable(e.metricsView.Database, e.metricsView.DatabaseSchema, e.metricsView.Table)) + + res, err := e.olap.Execute(ctx, &drivers.Statement{ + Query: sql, + Priority: e.priority, + ExecutionTimeout: defaultInteractiveTimeout, + }) + if err != nil { + return time.Time{}, err + } + defer res.Close() + + var t time.Time + if res.Next() { + if err := res.Scan(&t); err != nil { + return time.Time{}, fmt.Errorf("failed to scan time anchor: %w", err) + } + } + if res.Err() != nil { + return time.Time{}, fmt.Errorf("failed to scan time anchor: %w", res.Err()) + } + return t, nil +} + // Schema returns a schema for the metrics view's dimensions and measures. func (e *Executor) Schema(ctx context.Context) (*runtimev1.StructType, error) { if !e.security.CanAccess() { diff --git a/runtime/queries/metricsview_aggregation_test.go b/runtime/queries/metricsview_aggregation_test.go index 275ba972407..533333ccdd8 100644 --- a/runtime/queries/metricsview_aggregation_test.go +++ b/runtime/queries/metricsview_aggregation_test.go @@ -104,6 +104,9 @@ func TestMetricViewAggregationAgainstDuckDB(t *testing.T) { t.Run("testMetricsViewsAggregation_comparison_with_offset_and_limit_and_delta", func(t *testing.T) { testMetricsViewsAggregation_comparison_with_offset_and_limit_and_delta(t, rt, instanceID) }) + t.Run("testMetricsViewsAggregation_comparison_using_rill_time", func(t *testing.T) { + testMetricsViewsAggregation_comparison_using_rill_time(t, rt, instanceID) + }) } func testClaims() *runtime.SecurityClaims { @@ -4919,6 +4922,68 @@ func testMetricsViewAggregation_percent_of_totals_with_limit(t *testing.T, rt *r require.Equal(t, "news.google.com,256.00,0.23", fieldsToString2digits(rows[i], "domain", "total_records", "total_records__pt")) } +func testMetricsViewsAggregation_comparison_using_rill_time(t *testing.T, rt *runtime.Runtime, instanceID string) { + q := &queries.MetricsViewAggregation{ + MetricsViewName: "ad_bids_metrics", + Dimensions: []*runtimev1.MetricsViewAggregationDimension{ + { + Name: "dom", + }, + }, + Measures: []*runtimev1.MetricsViewAggregationMeasure{ + { + Name: "m1", + }, + { + Name: "m1__p", + Compute: &runtimev1.MetricsViewAggregationMeasure_ComparisonValue{ + ComparisonValue: &runtimev1.MetricsViewAggregationMeasureComputeComparisonValue{ + Measure: "m1", + }, + }, + }, + }, + Sort: []*runtimev1.MetricsViewAggregationSort{ + { + Name: "dom", + Desc: true, + }, + }, + + TimeRange: &runtimev1.TimeRange{ + Expression: "2022-03-01-7d,2022-03-01", + }, + ComparisonTimeRange: &runtimev1.TimeRange{ + Expression: "2022-03-01-7d,2022-03-01 @-7d", + }, + SecurityClaims: testClaims(), + } + err := q.Resolve(context.Background(), rt, instanceID, 0) + require.NoError(t, err) + require.NotEmpty(t, q.Result) + fields := q.Result.Schema.Fields + require.Equal(t, "dom,m1,m1__p", columnNames(fields)) + i := 0 + + rows := q.Result.Data + require.Equal(t, 7, len(rows)) + + i = 0 + require.Equal(t, "sports.yahoo.com,1.51,1.50", fieldsToString2digits(rows[i], "dom", "m1", "m1__p")) + i++ + require.Equal(t, "news.yahoo.com,3.81,3.85", fieldsToString2digits(rows[i], "dom", "m1", "m1__p")) + i++ + require.Equal(t, "news.google.com,1.50,1.50", fieldsToString2digits(rows[i], "dom", "m1", "m1__p")) + i++ + require.Equal(t, "msn.com,1.84,1.48", fieldsToString2digits(rows[i], "dom", "m1", "m1__p")) + i++ + require.Equal(t, "instagram.com,1.49,1.50", fieldsToString2digits(rows[i], "dom", "m1", "m1__p")) + i++ + require.Equal(t, "google.com,3.83,3.82", fieldsToString2digits(rows[i], "dom", "m1", "m1__p")) + i++ + require.Equal(t, "facebook.com,1.84,1.51", fieldsToString2digits(rows[i], "dom", "m1", "m1__p")) +} + func fieldsToString2digits(row *structpb.Struct, args ...string) string { s := make([]string, 0, len(args)) for _, arg := range args { diff --git a/web-admin/src/features/bookmarks/BookmarksDropdownMenuContent.svelte b/web-admin/src/features/bookmarks/BookmarksDropdownMenuContent.svelte index 5cc5aa11d1c..caa08fa8520 100644 --- a/web-admin/src/features/bookmarks/BookmarksDropdownMenuContent.svelte +++ b/web-admin/src/features/bookmarks/BookmarksDropdownMenuContent.svelte @@ -25,6 +25,7 @@ import { Search } from "@rilldata/web-common/components/search"; import { useMetricsViewTimeRange } from "@rilldata/web-common/features/dashboards/selectors"; import { useExploreState } from "@rilldata/web-common/features/dashboards/stores/dashboard-stores"; + import { getTimeRanges } from "@rilldata/web-common/features/dashboards/time-controls/time-ranges"; import { getDefaultExplorePreset } from "@rilldata/web-common/features/dashboards/url-state/getDefaultExplorePreset"; import { ResourceKind } from "@rilldata/web-common/features/entity-management/resource-selectors"; import { useExploreValidSpec } from "@rilldata/web-common/features/explores/selectors"; @@ -52,6 +53,7 @@ instanceId, metricsViewName, ); + $: timeRanges = getTimeRanges(exploreName); $: defaultExplorePreset = getDefaultExplorePreset( exploreSpec, $metricsViewTimeRange.data, @@ -84,7 +86,7 @@ $schemaResp.data?.schema, $exploreState, defaultExplorePreset, - $metricsViewTimeRange.data?.timeRangeSummary, + $timeRanges.data?.timeRanges ?? [], ); $: filteredBookmarks = searchBookmarks(categorizedBookmarks, searchText); diff --git a/web-admin/src/features/bookmarks/selectors.ts b/web-admin/src/features/bookmarks/selectors.ts index e77b2274049..ea0206cfd03 100644 --- a/web-admin/src/features/bookmarks/selectors.ts +++ b/web-admin/src/features/bookmarks/selectors.ts @@ -5,13 +5,13 @@ import { type V1Bookmark, } from "@rilldata/web-admin/client"; import { getDashboardStateFromUrl } from "@rilldata/web-common/features/dashboards/proto-state/fromProto"; -import { useMetricsViewTimeRange } from "@rilldata/web-common/features/dashboards/selectors"; import { useExploreState } from "@rilldata/web-common/features/dashboards/stores/dashboard-stores"; import type { MetricsExplorerEntity } from "@rilldata/web-common/features/dashboards/stores/metrics-explorer-entity"; import { getTimeControlState, timeControlStateSelector, } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store"; +import { getTimeRanges } from "@rilldata/web-common/features/dashboards/time-controls/time-ranges"; import { convertExploreStateToURLSearchParams } from "@rilldata/web-common/features/dashboards/url-state/convertExploreStateToURLSearchParams"; import { ResourceKind } from "@rilldata/web-common/features/entity-management/resource-selectors"; import { useExploreValidSpec } from "@rilldata/web-common/features/explores/selectors"; @@ -23,7 +23,7 @@ import { type V1ExploreSpec, type V1MetricsViewSpec, type V1StructType, - type V1TimeRangeSummary, + type V1TimeRange, } from "@rilldata/web-common/runtime-client"; import type { QueryClient } from "@tanstack/query-core"; import { derived, get, type Readable } from "svelte/store"; @@ -65,7 +65,7 @@ export function categorizeBookmarks( schema: V1StructType | undefined, exploreState: MetricsExplorerEntity, defaultExplorePreset: V1ExplorePreset, - timeRangeSummary: V1TimeRangeSummary | undefined, + timeRanges: V1TimeRange[], ) { const bookmarks: Bookmarks = { home: undefined, @@ -81,7 +81,7 @@ export function categorizeBookmarks( schema ?? {}, exploreState, defaultExplorePreset, - timeRangeSummary, + timeRanges, ); if (isHomeBookmark(bookmarkResource)) { bookmarks.home = bookmark; @@ -119,16 +119,14 @@ export function getPrettySelectedTimeRange( return derived( [ useExploreValidSpec(instanceId, exploreName), - useMetricsViewTimeRange(instanceId, metricsViewName, { - query: { queryClient }, - }), + getTimeRanges(exploreName), useExploreState(metricsViewName), ], - ([validSpec, timeRangeSummary, metricsExplorerEntity]) => { + ([validSpec, timeRanges, metricsExplorerEntity]) => { const timeRangeState = timeControlStateSelector([ validSpec.data?.metricsView ?? {}, validSpec.data?.explore ?? {}, - timeRangeSummary, + timeRanges, metricsExplorerEntity, ]); if (!timeRangeState.ready) return ""; @@ -149,7 +147,7 @@ function parseBookmark( schema: V1StructType, exploreState: MetricsExplorerEntity, defaultExplorePreset: V1ExplorePreset, - timeRangeSummary: V1TimeRangeSummary | undefined, + timeRanges: V1TimeRange[], ): BookmarkEntry { const exploreStateFromBookmark = getDashboardStateFromUrl( bookmarkResource.data ?? "", @@ -169,7 +167,7 @@ function parseBookmark( getTimeControlState( metricsViewSpec, exploreSpec, - timeRangeSummary, + timeRanges, finalExploreState, ), defaultExplorePreset, diff --git a/web-admin/src/features/dashboards/query-mappers/utils.ts b/web-admin/src/features/dashboards/query-mappers/utils.ts index f2c14056076..04b34b0bd6d 100644 --- a/web-admin/src/features/dashboards/query-mappers/utils.ts +++ b/web-admin/src/features/dashboards/query-mappers/utils.ts @@ -2,6 +2,7 @@ import { createInExpression } from "@rilldata/web-common/features/dashboards/sto import type { MetricsExplorerEntity } from "@rilldata/web-common/features/dashboards/stores/metrics-explorer-entity"; import { getTimeControlState } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store"; import { PreviousCompleteRangeMap } from "@rilldata/web-common/features/dashboards/time-controls/time-range-mappers"; +import { fetchTimeRanges } from "@rilldata/web-common/features/dashboards/time-controls/time-ranges"; import { convertExploreStateToURLSearchParams } from "@rilldata/web-common/features/dashboards/url-state/convertExploreStateToURLSearchParams"; import { getDefaultExplorePreset } from "@rilldata/web-common/features/dashboards/url-state/getDefaultExplorePreset"; import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient"; @@ -205,15 +206,15 @@ export async function getExplorePageUrl( }); } + let timeRanges: V1TimeRange[] = []; + if (metricsView?.metricsView?.state?.validSpec?.timeDimension) { + timeRanges = await fetchTimeRanges(exploreSpec); + } + url.search = convertExploreStateToURLSearchParams( exploreState, exploreSpec, - getTimeControlState( - metricsViewSpec, - exploreSpec, - fullTimeRange?.timeRangeSummary, - exploreState, - ), + getTimeControlState(metricsViewSpec, exploreSpec, timeRanges, exploreState), getDefaultExplorePreset(exploreSpec, fullTimeRange), ); return url.toString(); diff --git a/web-common/orval.config.ts b/web-common/orval.config.ts index b549da03e2f..f41ff01dedb 100644 --- a/web-common/orval.config.ts +++ b/web-common/orval.config.ts @@ -96,6 +96,12 @@ export default defineConfig({ signal: true, }, }, + QueryService_MetricsViewTimeRanges: { + query: { + useQuery: true, + signal: true, + }, + }, QueryService_ResolveComponent: { query: { useQuery: true, diff --git a/web-common/package.json b/web-common/package.json index 62d51197839..3072b15dc9e 100644 --- a/web-common/package.json +++ b/web-common/package.json @@ -13,7 +13,8 @@ "storybook": "STORYBOOK_MODE=true storybook dev -p 6006", "storybook:smoketest": "STORYBOOK_MODE=true storybook dev --smoke-test", "build-storybook": "storybook build", - "build-filter-grammar": "nearleyc src/features/dashboards/url-state/filters/expression.ne -o src/features/dashboards/url-state/filters/expression.cjs" + "build-filter-grammar": "nearleyc src/features/dashboards/url-state/filters/expression.ne -o src/features/dashboards/url-state/filters/expression.cjs", + "build-rill-time-grammar": "nearleyc src/features/dashboards/url-state/time-ranges/rill-time.ne -o src/features/dashboards/url-state/time-ranges/rill-time.cjs" }, "devDependencies": { "@bufbuild/protobuf": "^1.0.0", diff --git a/web-common/src/features/dashboards/pivot/pivot-data-config.ts b/web-common/src/features/dashboards/pivot/pivot-data-config.ts index 69d82d4a52d..8602ca4d83d 100644 --- a/web-common/src/features/dashboards/pivot/pivot-data-config.ts +++ b/web-common/src/features/dashboards/pivot/pivot-data-config.ts @@ -29,15 +29,15 @@ export function getPivotConfig( return derived( [ ctx.validSpecStore, - ctx.timeRangeSummaryStore, + ctx.timeRanges, ctx.dashboardStore, dimensionSearchText, ], - ([validSpec, timeRangeSummary, dashboardStore, searchText]) => { + ([validSpec, timeRanges, dashboardStore, searchText]) => { if ( !validSpec?.data?.metricsView || !validSpec?.data?.explore || - timeRangeSummary.isFetching + timeRanges.isFetching ) { return { measureNames: [], @@ -60,7 +60,7 @@ export function getPivotConfig( const timeControl = timeControlStateSelector([ metricsView, explore, - timeRangeSummary, + timeRanges, dashboardStore, ]); diff --git a/web-common/src/features/dashboards/state-managers/selectors/index.ts b/web-common/src/features/dashboards/state-managers/selectors/index.ts index 5271d6e952b..e533cfb10f4 100644 --- a/web-common/src/features/dashboards/state-managers/selectors/index.ts +++ b/web-common/src/features/dashboards/state-managers/selectors/index.ts @@ -3,6 +3,7 @@ import { measureFilterSelectors } from "@rilldata/web-common/features/dashboards import type { ExploreValidSpecResponse } from "@rilldata/web-common/features/explores/selectors"; import type { RpcStatus, + V1MetricsViewResolveTimeRangesResponse, V1MetricsViewTimeRangeResponse, } from "@rilldata/web-common/runtime-client"; import type { QueryClient, QueryObserverResult } from "@tanstack/svelte-query"; @@ -29,6 +30,9 @@ export type DashboardDataReadables = { timeRangeSummaryStore: Readable< QueryObserverResult >; + timeRanges: Readable< + QueryObserverResult + >; queryClient: QueryClient; }; @@ -167,13 +171,15 @@ function createReadablesFromSelectors( readables.dashboardStore, readables.validSpecStore, readables.timeRangeSummaryStore, + readables.timeRanges, ], - ([dashboard, validSpec, timeRangeSummary]) => + ([dashboard, validSpec, timeRangeSummary, timeRanges]) => selectorFn({ dashboard, validMetricsView: validSpec.data?.metricsView, validExplore: validSpec.data?.explore, timeRangeSummary, + timeRanges, queryClient: readables.queryClient, }), ), diff --git a/web-common/src/features/dashboards/state-managers/selectors/time-range.ts b/web-common/src/features/dashboards/state-managers/selectors/time-range.ts index cb3231ac0ca..489d67da57d 100644 --- a/web-common/src/features/dashboards/state-managers/selectors/time-range.ts +++ b/web-common/src/features/dashboards/state-managers/selectors/time-range.ts @@ -1,7 +1,4 @@ -import { - timeComparisonOptionsSelector, - timeRangeSelectionsSelector, -} from "@rilldata/web-common/features/dashboards/time-controls/time-range-store"; +import { timeComparisonOptionsSelector } from "@rilldata/web-common/features/dashboards/time-controls/time-range-store"; import { TimeComparisonOption, TimeRangePreset, @@ -16,7 +13,7 @@ export const timeControlsState = (dashData: DashboardDataSources) => timeControlStateSelector([ dashData.validMetricsView, dashData.validExplore, - dashData.timeRangeSummary, + dashData.timeRanges, dashData.dashboard, ]); @@ -27,14 +24,6 @@ export const isTimeComparisonActive = ( dashData: DashboardDataSources, ): boolean => timeControlsState(dashData).showTimeComparison === true; -export const timeRangeSelectorState = (dashData: DashboardDataSources) => - timeRangeSelectionsSelector([ - dashData.validMetricsView, - dashData.validExplore, - dashData.timeRangeSummary, - dashData.dashboard, - ]); - export const timeComparisonOptionsState = (dashData: DashboardDataSources) => timeComparisonOptionsSelector([ dashData.validMetricsView, @@ -73,11 +62,6 @@ export const timeRangeSelectors = { */ isTimeComparisonActive, - /** - * Selection options for the time range selector - */ - timeRangeSelectorState, - /** * Selection options for the time comparison selector */ diff --git a/web-common/src/features/dashboards/state-managers/selectors/types.ts b/web-common/src/features/dashboards/state-managers/selectors/types.ts index ffa56f4615b..5653888167e 100644 --- a/web-common/src/features/dashboards/state-managers/selectors/types.ts +++ b/web-common/src/features/dashboards/state-managers/selectors/types.ts @@ -1,5 +1,7 @@ import type { + RpcStatus, V1ExploreSpec, + V1MetricsViewResolveTimeRangesResponse, V1MetricsViewSpec, V1MetricsViewTimeRangeResponse, } from "@rilldata/web-common/runtime-client"; @@ -29,6 +31,10 @@ export type DashboardDataSources = { V1MetricsViewTimeRangeResponse, unknown >; + timeRanges: QueryObserverResult< + V1MetricsViewResolveTimeRangesResponse, + RpcStatus + >; queryClient: QueryClient; }; diff --git a/web-common/src/features/dashboards/state-managers/state-managers.ts b/web-common/src/features/dashboards/state-managers/state-managers.ts index 33e23aecf6e..bdeb210d53a 100644 --- a/web-common/src/features/dashboards/state-managers/state-managers.ts +++ b/web-common/src/features/dashboards/state-managers/state-managers.ts @@ -1,7 +1,7 @@ import { + contextColWidthDefaults, type ContextColWidths, type MetricsExplorerEntity, - contextColWidthDefaults, } from "@rilldata/web-common/features/dashboards/stores/metrics-explorer-entity"; import { createPersistentDashboardStore } from "@rilldata/web-common/features/dashboards/stores/persistent-dashboard-state"; import { getDefaultExplorePreset } from "@rilldata/web-common/features/dashboards/url-state/getDefaultExplorePreset"; @@ -10,34 +10,37 @@ import { type ExploreValidSpecResponse, useExploreValidSpec, } from "@rilldata/web-common/features/explores/selectors"; +import { dedupe } from "@rilldata/web-common/lib/arrayUtils"; import { + createQueryServiceMetricsViewTimeRange, + createQueryServiceMetricsViewTimeRanges, type RpcStatus, type V1ExplorePreset, type V1MetricsViewTimeRangeResponse, - createQueryServiceMetricsViewTimeRange, + type V1MetricsViewTimeRangesResponse, } from "@rilldata/web-common/runtime-client"; import type { Runtime } from "@rilldata/web-common/runtime-client/runtime-store"; import { runtime } from "@rilldata/web-common/runtime-client/runtime-store"; import type { QueryClient, QueryObserverResult } from "@tanstack/svelte-query"; import { getContext } from "svelte"; import { - type Readable, - type Writable, derived, get, + type Readable, + type Writable, writable, } from "svelte/store"; import { - type MetricsExplorerStoreType, metricsExplorerStore, + type MetricsExplorerStoreType, updateMetricsExplorerByName, useExploreState, } from "web-common/src/features/dashboards/stores/dashboard-stores"; -import { type StateManagerActions, createStateManagerActions } from "./actions"; +import { createStateManagerActions, type StateManagerActions } from "./actions"; import type { DashboardCallbackExecutor } from "./actions/types"; import { - type StateManagerReadables, createStateManagerReadables, + type StateManagerReadables, } from "./selectors"; export type StateManagers = { @@ -49,6 +52,9 @@ export type StateManagers = { timeRangeSummaryStore: Readable< QueryObserverResult >; + timeRanges: Readable< + QueryObserverResult + >; validSpecStore: Readable< QueryObserverResult >; @@ -128,6 +134,31 @@ export function createStateManagers({ ).subscribe(set), ); + const timeRanges: Readable< + QueryObserverResult + > = derived( + [runtime, metricsViewNameStore, validSpecStore], + ([runtime, mvName, validSpec], set) => { + if (!validSpec.data?.explore) { + return; + } + + const explore = validSpec.data.explore; + const defaultPreset = explore.defaultPreset ?? {}; + const rillTimes = dedupe([ + "inf", + ...(defaultPreset.timeRange ? [defaultPreset.timeRange] : []), + ...(explore.timeRanges?.length + ? explore.timeRanges.map((t) => t.range!) + : []), + ]); + + createQueryServiceMetricsViewTimeRanges(runtime.instanceId, mvName, { + expressions: rillTimes, + }).subscribe(set); + }, + ); + const updateDashboard = ( callback: (metricsExplorer: MetricsExplorerEntity) => void, ) => { @@ -164,6 +195,7 @@ export function createStateManagers({ exploreName: exploreNameStore, metricsStore: metricsExplorerStore, timeRangeSummaryStore, + timeRanges, validSpecStore, queryClient, dashboardStore, @@ -176,6 +208,7 @@ export function createStateManagers({ dashboardStore, validSpecStore, timeRangeSummaryStore, + timeRanges, queryClient, }), /** diff --git a/web-common/src/features/dashboards/time-controls/new-time-controls.ts b/web-common/src/features/dashboards/time-controls/new-time-controls.ts index 6cdf06c8575..3fffc401192 100644 --- a/web-common/src/features/dashboards/time-controls/new-time-controls.ts +++ b/web-common/src/features/dashboards/time-controls/new-time-controls.ts @@ -1,5 +1,10 @@ // WIP as of 04/19/2024 +import { parseRillTime } from "@rilldata/web-common/features/dashboards/url-state/time-ranges/parser"; +import { + type RillTime, + RillTimeType, +} from "@rilldata/web-common/features/dashboards/url-state/time-ranges/RillTime"; import { humaniseISODuration } from "@rilldata/web-common/lib/time/ranges/iso-ranges"; import { writable, type Writable, get } from "svelte/store"; import { @@ -10,7 +15,7 @@ import { Duration, IANAZone, } from "luxon"; -import type { MetricsViewSpecAvailableTimeRange } from "@rilldata/web-common/runtime-client"; +import type { V1TimeRange } from "@rilldata/web-common/runtime-client"; // CONSTANTS -> time-control-constants.ts @@ -84,8 +89,8 @@ export type RillPreviousPeriod = RillPreviousPeriodTuple[number]; type RillLatestTuple = typeof RILL_LATEST; export type RillLatest = RillLatestTuple[number]; -export const CUSTOM_TIME_RANGE_ALIAS = "CUSTOM" as const; -export const ALL_TIME_RANGE_ALIAS = "inf" as const; +export const CUSTOM_TIME_RANGE_ALIAS = "CUSTOM"; +export const ALL_TIME_RANGE_ALIAS = "inf"; export type AllTime = typeof ALL_TIME_RANGE_ALIAS; export type CustomRange = typeof CUSTOM_TIME_RANGE_ALIAS; export type ISODurationString = string; @@ -380,53 +385,77 @@ export function getRangeLabel(range: NamedRange | ISODurationString): string { // BUCKETS FOR DISPLAYING IN DROPDOWN (yaml spec may make this unnecessary) +export type RangeBucket = { + range: string; + label: string; + shortLabel: string; +}; export type RangeBuckets = { - latest: { label: string; range: ISODurationString }[]; - previous: { range: RillPreviousPeriod; label: string }[]; - periodToDate: { range: RillPeriodToDate; label: string }[]; + latest: RangeBucket[]; + previous: RangeBucket[]; + periodToDate: RangeBucket[]; }; const defaultBuckets = { previous: RILL_PREVIOUS_PERIOD.map((range) => ({ range, label: RILL_TO_LABEL[range], + shortLabel: RILL_TO_LABEL[range], })), latest: RILL_LATEST.map((range) => ({ range, label: getDurationLabel(range), + shortLabel: getDurationLabel(range), })), periodToDate: RILL_PERIOD_TO_DATE.map((range) => ({ range, label: RILL_TO_LABEL[range], + shortLabel: RILL_TO_LABEL[range], })), }; -export function bucketYamlRanges( - availableRanges: MetricsViewSpecAvailableTimeRange[], -): RangeBuckets { - const showDefaults = !availableRanges.length; - +export function bucketTimeRanges(timeRanges: V1TimeRange[]) { + const showDefaults = !timeRanges.length; if (showDefaults) { return defaultBuckets; } - return availableRanges.reduce( - (record, { range }) => { - if (!range) return record; - - if (isRillPeriodToDate(range)) { - record.periodToDate.push({ range, label: RILL_TO_LABEL[range] }); - } else if (isRillPreviousPeriod(range)) { - record.previous.push({ range, label: RILL_TO_LABEL[range] }); - } else { - record.latest.push({ range, label: getDurationLabel(range) }); + return timeRanges.reduce( + (buckets, timeRange) => { + if (!timeRange) return buckets; + let rillTime: RillTime | null = null; + try { + rillTime = parseRillTime(timeRange.expression ?? ""); + } catch { + // no-op + } + if (!rillTime) return buckets; + + const bucket = { + range: timeRange.expression ?? "", + label: rillTime.getLabel(), + shortLabel: rillTime.getLabel(), + }; + + switch (rillTime.type) { + case RillTimeType.Unknown: + case RillTimeType.Latest: + buckets.latest.push(bucket); + break; + case RillTimeType.PreviousPeriod: + buckets.previous.push(bucket); + break; + case RillTimeType.PeriodToDate: + buckets.periodToDate.push(bucket); + break; } - return record; + return buckets; }, { previous: [], latest: [], + latestIncomplete: [], periodToDate: [], }, ); diff --git a/web-common/src/features/dashboards/time-controls/super-pill/SuperPill.svelte b/web-common/src/features/dashboards/time-controls/super-pill/SuperPill.svelte index 178c9f79243..551b33dcfd8 100644 --- a/web-common/src/features/dashboards/time-controls/super-pill/SuperPill.svelte +++ b/web-common/src/features/dashboards/time-controls/super-pill/SuperPill.svelte @@ -1,6 +1,8 @@ - {getRangeLabel(selected)} + {selectedLabel} {#if interval.isValid} {/if} diff --git a/web-common/src/features/dashboards/time-controls/time-control-store.ts b/web-common/src/features/dashboards/time-controls/time-control-store.ts index b2a4a254dbe..6ea86d295cc 100644 --- a/web-common/src/features/dashboards/time-controls/time-control-store.ts +++ b/web-common/src/features/dashboards/time-controls/time-control-store.ts @@ -1,5 +1,6 @@ import type { StateManagers } from "@rilldata/web-common/features/dashboards/state-managers/state-managers"; import type { MetricsExplorerEntity } from "@rilldata/web-common/features/dashboards/stores/metrics-explorer-entity"; +import { ALL_TIME_RANGE_ALIAS } from "@rilldata/web-common/features/dashboards/time-controls/new-time-controls"; import { getOrderedStartEnd } from "@rilldata/web-common/features/dashboards/time-series/utils"; import { getComparionRangeForScrub, @@ -7,17 +8,13 @@ import { getTimeComparisonParametersForComponent, inferCompareTimeRange, } from "@rilldata/web-common/lib/time/comparisons"; -import { DEFAULT_TIME_RANGES } from "@rilldata/web-common/lib/time/config"; import { checkValidTimeGrain, findValidTimeGrain, getAllowedTimeGrains, getDefaultTimeGrain, } from "@rilldata/web-common/lib/time/grains"; -import { - convertTimeRangePreset, - getAdjustedFetchTime, -} from "@rilldata/web-common/lib/time/ranges"; +import { getAdjustedFetchTime } from "@rilldata/web-common/lib/time/ranges"; import { isoDurationToFullTimeRange } from "@rilldata/web-common/lib/time/ranges/iso-ranges"; import { type DashboardTimeControls, @@ -26,11 +23,13 @@ import { TimeRangePreset, } from "@rilldata/web-common/lib/time/types"; import { + type RpcStatus, type V1ExploreSpec, type V1MetricsViewSpec, type V1MetricsViewTimeRangeResponse, + type V1MetricsViewTimeRangesResponse, V1TimeGrain, - type V1TimeRangeSummary, + type V1TimeRange, } from "@rilldata/web-common/runtime-client"; import type { QueryObserverResult } from "@tanstack/svelte-query"; import type { Readable } from "svelte/store"; @@ -78,12 +77,12 @@ export type TimeControlStore = Readable; export const timeControlStateSelector = ([ metricsView, explore, - timeRangeResponse, + timeRanges, metricsExplorer, ]: [ V1MetricsViewSpec | undefined, V1ExploreSpec | undefined, - QueryObserverResult, + QueryObserverResult, MetricsExplorerEntity, ]): TimeControlState => { const hasTimeSeries = Boolean(metricsView?.timeDimension); @@ -91,12 +90,12 @@ export const timeControlStateSelector = ([ !metricsView || !explore || !metricsExplorer || - !timeRangeResponse || - !timeRangeResponse.isSuccess || + !timeRanges.isSuccess || + !timeRanges?.data?.timeRanges || !hasTimeSeries ) { return { - isFetching: timeRangeResponse.isRefetching, + isFetching: timeRanges.isRefetching, ready: !metricsExplorer || !hasTimeSeries, } as TimeControlState; } @@ -104,7 +103,7 @@ export const timeControlStateSelector = ([ const state = getTimeControlState( metricsView, explore, - timeRangeResponse.data?.timeRangeSummary, + timeRanges.data.timeRanges, metricsExplorer, ); if (!state) { @@ -129,19 +128,19 @@ export const timeControlStateSelector = ([ export function getTimeControlState( metricsViewSpec: V1MetricsViewSpec, exploreSpec: V1ExploreSpec, - timeRangeSummary: V1TimeRangeSummary | undefined, + timeRanges: V1TimeRange[], exploreState: MetricsExplorerEntity, ) { const hasTimeSeries = Boolean(metricsViewSpec.timeDimension); const timeDimension = metricsViewSpec.timeDimension; - if (!hasTimeSeries || !timeRangeSummary?.max || !timeRangeSummary?.min) - return undefined; + if (!hasTimeSeries) return undefined; + + const allTimeRange = findTimeRange( + ALL_TIME_RANGE_ALIAS, + timeRanges, + ) as DashboardTimeControls; + if (!allTimeRange) return undefined; - const allTimeRange = { - name: TimeRangePreset.ALL_TIME, - start: new Date(timeRangeSummary.min), - end: new Date(timeRangeSummary.max), - }; const minTimeGrain = (metricsViewSpec.smallestTimeGrain as V1TimeGrain) || V1TimeGrain.TIME_GRAIN_UNSPECIFIED; @@ -154,9 +153,9 @@ export function getTimeControlState( const timeRangeState = calculateTimeRangePartial( exploreState, - allTimeRange, defaultTimeRange, minTimeGrain, + timeRanges, ); if (!timeRangeState) { return undefined; @@ -183,12 +182,12 @@ export function getTimeControlState( export function createTimeControlStore(ctx: StateManagers) { return derived( - [ctx.validSpecStore, ctx.timeRangeSummaryStore, ctx.dashboardStore], - ([validSpecResp, timeRangeSummaryResp, dashboardStore]) => + [ctx.validSpecStore, ctx.timeRanges, ctx.dashboardStore], + ([validSpecResp, timeRangeResp, dashboardStore]) => timeControlStateSelector([ validSpecResp.data?.metricsView, validSpecResp.data?.explore, - timeRangeSummaryResp, + timeRangeResp, dashboardStore, ]), ); @@ -207,17 +206,16 @@ export const useTimeControlStore = memoizeMetricsStore( */ function calculateTimeRangePartial( metricsExplorer: MetricsExplorerEntity, - allTimeRange: DashboardTimeControls, defaultTimeRange: DashboardTimeControls, minTimeGrain: V1TimeGrain, + timeRanges: V1TimeRange[], ): TimeRangeState | undefined { if (!metricsExplorer.selectedTimeRange) return undefined; const selectedTimeRange = getTimeRange( - metricsExplorer.selectedTimeRange, - metricsExplorer.selectedTimezone, - allTimeRange, + metricsExplorer, defaultTimeRange, + timeRanges, ); if (!selectedTimeRange) return undefined; @@ -317,50 +315,25 @@ function calculateComparisonTimeRangePartial( }; } -export function getTimeRange( - selectedTimeRange: DashboardTimeControls | undefined, - selectedTimezone: string, - allTimeRange: DashboardTimeControls, +function getTimeRange( + metricsExplorer: MetricsExplorerEntity, defaultTimeRange: DashboardTimeControls, + timeRanges: V1TimeRange[], ) { - if (!selectedTimeRange) return undefined; - - let timeRange: DashboardTimeControls; - - if (selectedTimeRange?.name === TimeRangePreset.CUSTOM) { + if (!metricsExplorer.selectedTimeRange) return undefined; + if (!metricsExplorer.selectedTimeRange?.name) { + return defaultTimeRange; + } + if (metricsExplorer.selectedTimeRange.name === TimeRangePreset.CUSTOM) { /** set the time range to the fixed custom time range */ - timeRange = { + return { name: TimeRangePreset.CUSTOM, - start: new Date(selectedTimeRange.start), - end: new Date(selectedTimeRange.end), - }; - } else if (selectedTimeRange?.name) { - if (selectedTimeRange?.name in DEFAULT_TIME_RANGES) { - /** rebuild off of relative time range */ - timeRange = convertTimeRangePreset( - selectedTimeRange?.name ?? TimeRangePreset.ALL_TIME, - allTimeRange.start, - allTimeRange.end, - selectedTimezone, - ); - } else { - timeRange = isoDurationToFullTimeRange( - selectedTimeRange?.name, - allTimeRange.start, - allTimeRange.end, - selectedTimezone, - ); - } - } else { - /** set the time range to the fixed custom time range */ - timeRange = { - name: defaultTimeRange.name, - start: defaultTimeRange.start, - end: defaultTimeRange.end, + start: new Date(metricsExplorer.selectedTimeRange.start), + end: new Date(metricsExplorer.selectedTimeRange.end), }; } - return timeRange; + return findTimeRange(metricsExplorer.selectedTimeRange?.name, timeRanges); } export function getTimeGrain( @@ -477,10 +450,18 @@ export function selectedTimeRangeSelector([ explorer.selectedTimezone, ); - return getTimeRange( - explorer.selectedTimeRange, - explorer.selectedTimezone, - allTimeRange, - defaultTimeRange, - ); + return getTimeRange(explorer, defaultTimeRange, []); +} + +export function findTimeRange( + name: string, + timeRanges: V1TimeRange[], +): DashboardTimeControls | undefined { + const tr = timeRanges.find((tr) => tr.expression === name); + if (!tr) return undefined; + return { + name: name as TimeRangePreset, + start: new Date(tr.start ?? ""), + end: new Date(tr.end ?? ""), + }; } diff --git a/web-common/src/features/dashboards/time-controls/time-range-store.ts b/web-common/src/features/dashboards/time-controls/time-range-store.ts index 77b943a7d7c..77b483a5f38 100644 --- a/web-common/src/features/dashboards/time-controls/time-range-store.ts +++ b/web-common/src/features/dashboards/time-controls/time-range-store.ts @@ -37,6 +37,7 @@ export type TimeRangeControlsState = { showDefaultItem: boolean; }; +// TODO: cleanup export function timeRangeSelectionsSelector([ metricsView, explore, diff --git a/web-common/src/features/dashboards/time-controls/time-ranges.ts b/web-common/src/features/dashboards/time-controls/time-ranges.ts new file mode 100644 index 00000000000..9117cf904d3 --- /dev/null +++ b/web-common/src/features/dashboards/time-controls/time-ranges.ts @@ -0,0 +1,66 @@ +import { useExploreValidSpec } from "@rilldata/web-common/features/explores/selectors"; +import { dedupe } from "@rilldata/web-common/lib/arrayUtils"; +import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient"; +import { + createQueryServiceMetricsViewTimeRanges, + getQueryServiceMetricsViewTimeRangesQueryKey, + queryServiceMetricsViewTimeRanges, + type V1ExploreSpec, + type V1MetricsViewTimeRangesResponse, +} from "@rilldata/web-common/runtime-client"; +import { runtime } from "@rilldata/web-common/runtime-client/runtime-store"; +import type { CreateQueryResult } from "@tanstack/svelte-query"; +import { derived, get } from "svelte/store"; + +export function getTimeRanges(exploreName: string) { + return derived( + [useExploreValidSpec(get(runtime).instanceId, exploreName)], + ([validSpecResp], set) => { + if (!validSpecResp.data?.explore) { + return; + } + + const explore = validSpecResp.data.explore; + const defaultPreset = explore.defaultPreset ?? {}; + const rillTimes = dedupe([ + ...(defaultPreset.timeRange ? [defaultPreset.timeRange] : []), + ...(explore.timeRanges?.length + ? explore.timeRanges.map((t) => t.range!) + : []), + ]); + + createQueryServiceMetricsViewTimeRanges( + get(runtime).instanceId, + explore.metricsView!, + { + expressions: rillTimes, + }, + ).subscribe(set); + }, + ) as CreateQueryResult; +} + +export async function fetchTimeRanges(exploreSpec: V1ExploreSpec) { + const defaultPreset = exploreSpec.defaultPreset ?? {}; + const rillTimes = dedupe([ + ...(defaultPreset.timeRange ? [defaultPreset.timeRange] : []), + ...(exploreSpec.timeRanges?.length + ? exploreSpec.timeRanges.map((t) => t.range!) + : []), + ]); + const instanceId = get(runtime).instanceId; + const metricsViewName = exploreSpec.metricsView!; + + const timeRangesResp = await queryClient.fetchQuery({ + queryKey: getQueryServiceMetricsViewTimeRangesQueryKey( + instanceId, + metricsViewName, + { expressions: rillTimes }, + ), + queryFn: () => + queryServiceMetricsViewTimeRanges(instanceId, metricsViewName, { + expressions: rillTimes, + }), + }); + return timeRangesResp.timeRanges ?? []; +} diff --git a/web-common/src/features/dashboards/url-state/DashboardURLStateSync.svelte b/web-common/src/features/dashboards/url-state/DashboardURLStateSync.svelte index d896297d759..ce6ae6024f5 100644 --- a/web-common/src/features/dashboards/url-state/DashboardURLStateSync.svelte +++ b/web-common/src/features/dashboards/url-state/DashboardURLStateSync.svelte @@ -40,7 +40,7 @@ | Partial | undefined; - const { dashboardStore, validSpecStore, timeRangeSummaryStore } = + const { dashboardStore, validSpecStore, timeRangeSummaryStore, timeRanges } = getStateManagers(); $: exploreSpec = $validSpecStore.data?.explore; $: metricsSpec = $validSpecStore.data?.metricsView; @@ -52,11 +52,8 @@ metricsViewName, ); $: ({ error: schemaError } = $metricsViewSchema); - $: ({ - error, - data: timeRangeSummaryResp, - isLoading: timeRangeSummaryIsLoading, - } = $timeRangeSummaryStore); + $: ({ error, isLoading: timeRangeSummaryIsLoading } = $timeRangeSummaryStore); + $: ({ data: timeRangesResp, isLoading: timeRangesIsLoading } = $timeRanges); $: timeRangeSummaryError = error as HTTPError; let timeControlsState: TimeControlState | undefined = undefined; @@ -64,7 +61,7 @@ timeControlsState = getTimeControlState( metricsSpec, exploreSpec, - timeRangeSummaryResp?.timeRangeSummary, + timeRangesResp?.timeRanges ?? [], $dashboardStore, ); } @@ -205,13 +202,13 @@ // time range summary query has `enabled` based on `metricsSpec.timeDimension` // isLoading will never be true when the query is disabled, so we need this check before waiting for it. if (metricsSpec.timeDimension) { - await waitUntil(() => !timeRangeSummaryIsLoading); + await waitUntil(() => !timeRangeSummaryIsLoading || !timeRangesIsLoading); } metricsExplorerStore.init(exploreName, initState); timeControlsState ??= getTimeControlState( metricsSpec, exploreSpec, - timeRangeSummaryResp?.timeRangeSummary, + timeRangesResp?.timeRanges ?? [], get(metricsExplorerStore).entities[exploreName], ); const redirectUrl = new URL($page.url); diff --git a/web-common/src/features/dashboards/url-state/DashboardURLStateSyncWrapper.svelte b/web-common/src/features/dashboards/url-state/DashboardURLStateSyncWrapper.svelte index e393aef5a71..722420fe3bc 100644 --- a/web-common/src/features/dashboards/url-state/DashboardURLStateSyncWrapper.svelte +++ b/web-common/src/features/dashboards/url-state/DashboardURLStateSyncWrapper.svelte @@ -39,6 +39,7 @@ metricsViewSpec, exploreSpec, defaultExplorePreset, + [], // TODO )); let partialExploreStateFromUrl: Partial = {}; @@ -54,6 +55,7 @@ metricsViewSpec, exploreSpec, defaultExplorePreset, + [], // TODO )); } diff --git a/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts b/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts index 51fe24585bd..022d8c1a5a9 100644 --- a/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts +++ b/web-common/src/features/dashboards/url-state/convertPresetToExploreState.ts @@ -5,6 +5,7 @@ import { } from "@rilldata/web-common/features/dashboards/pivot/types"; import { SortDirection } from "@rilldata/web-common/features/dashboards/proto-state/derived-types"; import type { MetricsExplorerEntity } from "@rilldata/web-common/features/dashboards/stores/metrics-explorer-entity"; +import { findTimeRange } from "@rilldata/web-common/features/dashboards/time-controls/time-control-store"; import { TDDChart } from "@rilldata/web-common/features/dashboards/time-dimension-details/types"; import { convertURLToExplorePreset } from "@rilldata/web-common/features/dashboards/url-state/convertURLToExplorePreset"; import { @@ -40,6 +41,7 @@ import { type V1ExploreSpec, V1ExploreWebView, type V1MetricsViewSpec, + type V1TimeRange, } from "@rilldata/web-common/runtime-client"; import type { SortingState } from "@tanstack/svelte-table"; @@ -48,6 +50,7 @@ export function convertURLToExploreState( metricsView: V1MetricsViewSpec, exploreSpec: V1ExploreSpec, defaultExplorePreset: V1ExplorePreset, + timeRanges: V1TimeRange[], ) { const errors: Error[] = []; const { preset, errors: errorsFromPreset } = convertURLToExplorePreset( @@ -58,7 +61,7 @@ export function convertURLToExploreState( ); errors.push(...errorsFromPreset); const { partialExploreState, errors: errorsFromEntity } = - convertPresetToExploreState(metricsView, exploreSpec, preset); + convertPresetToExploreState(metricsView, exploreSpec, preset, timeRanges); errors.push(...errorsFromEntity); return { partialExploreState, errors }; } @@ -71,6 +74,7 @@ export function convertPresetToExploreState( metricsView: V1MetricsViewSpec, explore: V1ExploreSpec, preset: V1ExplorePreset, + timeRanges: V1TimeRange[], ) { const partialExploreState: Partial = {}; const errors: Error[] = []; @@ -102,7 +106,7 @@ export function convertPresetToExploreState( } const { partialExploreState: trPartialState, errors: trErrors } = - fromTimeRangesParams(preset, dimensions); + fromTimeRangesParams(preset, dimensions, timeRanges); Object.assign(partialExploreState, trPartialState); errors.push(...trErrors); @@ -127,6 +131,7 @@ export function convertPresetToExploreState( function fromTimeRangesParams( preset: V1ExplorePreset, dimensions: Map, + timeRanges: V1TimeRange[], ) { const partialExploreState: Partial = {}; const errors: Error[] = []; @@ -134,6 +139,7 @@ function fromTimeRangesParams( if (preset.timeRange) { partialExploreState.selectedTimeRange = fromTimeRangeUrlParam( preset.timeRange, + timeRanges, ); } @@ -150,6 +156,7 @@ function fromTimeRangesParams( if (preset.compareTimeRange) { partialExploreState.selectedComparisonTimeRange = fromTimeRangeUrlParam( preset.compareTimeRange, + timeRanges, ); partialExploreState.showTimeComparison = true; setCompareTimeRange = true; @@ -191,7 +198,7 @@ function fromTimeRangesParams( if (preset.selectTimeRange) { partialExploreState.lastDefinedScrubRange = partialExploreState.selectedScrubRange = { - ...fromTimeRangeUrlParam(preset.selectTimeRange), + ...fromTimeRangeUrlParam(preset.selectTimeRange, timeRanges), isScrubbing: false, }; } else { @@ -213,7 +220,7 @@ function fromTimeRangesParams( export const CustomTimeRangeRegex = /(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z),(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z)/; -function fromTimeRangeUrlParam(tr: string) { +function fromTimeRangeUrlParam(tr: string, timeRanges: V1TimeRange[]) { const customTimeRangeMatch = CustomTimeRangeRegex.exec(tr); if (customTimeRangeMatch?.length) { const [, start, end] = customTimeRangeMatch; @@ -223,7 +230,11 @@ function fromTimeRangeUrlParam(tr: string) { end: new Date(end), } as DashboardTimeControls; } + + const foundTimeRange = findTimeRange(tr, timeRanges) ?? {}; + return { + ...foundTimeRange, name: tr, } as DashboardTimeControls; } diff --git a/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts b/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts index b910367f677..208a81e385d 100644 --- a/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts +++ b/web-common/src/features/dashboards/url-state/convertURLToExplorePreset.ts @@ -21,6 +21,7 @@ import { FromURLParamTimeRangePresetMap, FromURLParamViewMap, } from "@rilldata/web-common/features/dashboards/url-state/mappers"; +import { validateRillTime } from "@rilldata/web-common/features/dashboards/url-state/time-ranges/parser"; import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params"; import { getMapFromArray, @@ -256,7 +257,13 @@ function fromTimeRangesParams( ) { preset.timeRange = tr; } else { - errors.push(getSingleFieldError("time range", tr)); + // TODO: revisit this assignment + const rillTimeError = validateRillTime(tr); + if (rillTimeError) { + errors.push(getSingleFieldError("time range", tr)); + } else { + preset.timeRange = tr; + } } } diff --git a/web-common/src/features/dashboards/url-state/getExploreStateFromSessionStorage.ts b/web-common/src/features/dashboards/url-state/getExploreStateFromSessionStorage.ts index 3377870cd93..8af4616ee63 100644 --- a/web-common/src/features/dashboards/url-state/getExploreStateFromSessionStorage.ts +++ b/web-common/src/features/dashboards/url-state/getExploreStateFromSessionStorage.ts @@ -68,6 +68,7 @@ export function getExploreStateFromSessionStorage( metricsViewSpec, exploreSpec, explorePresetFromSessionStorage, + [], // TODO ); return { diff --git a/web-common/src/features/dashboards/url-state/time-ranges/RillTime.ts b/web-common/src/features/dashboards/url-state/time-ranges/RillTime.ts new file mode 100644 index 00000000000..2327493a7f6 --- /dev/null +++ b/web-common/src/features/dashboards/url-state/time-ranges/RillTime.ts @@ -0,0 +1,170 @@ +export enum RillTimeType { + Unknown = "Unknown", + Latest = "Latest", + PreviousPeriod = "Previous period", + PeriodToDate = "Period To Date", +} + +export class RillTime { + public timeRange: string; + public readonly isComplete: boolean; + public readonly end: RillTimeAnchor; + public readonly type: RillTimeType; + + public constructor( + public readonly start: RillTimeAnchor, + end: RillTimeAnchor, + public readonly timeRangeGrain: RillTimeRangeGrain | undefined, + public readonly modifier: RillTimeRangeModifier | undefined, + ) { + this.type = start.getType(); + + this.end = end ?? RillTimeAnchor.now(); + this.isComplete = + this.end.type === RillTimeAnchorType.Relative || + this.end.truncate !== undefined; + } + + public getLabel() { + if (this.type === RillTimeType.Unknown || !!this.modifier) { + return this.timeRange; + } + + const start = capitalizeFirstChar(this.start.getLabel()); + const hasNonStandardStart = + this.start.type === RillTimeAnchorType.Custom || !!this.start.offset; + const hasNonStandardEnd = + this.end && + ((this.end.type === RillTimeAnchorType.Relative && + this.end.grain && + this.end.grain.count !== 0) || + this.end.type === RillTimeAnchorType.Custom || + !!this.end.offset); + if (hasNonStandardStart || hasNonStandardEnd) { + return this.timeRange; + } + + if (this.isComplete) return start; + return `${start}, incomplete`; + } +} + +export enum RillTimeAnchorType { + Now = "Now", + Earliest = "Earliest", + Latest = "Latest", + Relative = "Relative", + Custom = "Custom", +} + +const GrainToUnit = { + s: "second", + m: "minute", + h: "hour", + d: "day", + D: "day", + W: "week", + M: "month", + Q: "Quarter", + Y: "year", +}; +export const InvalidTime = "Invalid"; +export class RillTimeAnchor { + public truncate: RillTimeGrain | undefined = undefined; + public absolute: string | undefined = undefined; + public grain: RillTimeGrain | undefined = undefined; + public offset: RillTimeGrain | undefined = undefined; + + public constructor(public readonly type: RillTimeAnchorType) {} + + public static now() { + return new RillTimeAnchor(RillTimeAnchorType.Now); + } + public static earliest() { + return new RillTimeAnchor(RillTimeAnchorType.Earliest); + } + public static latest() { + return new RillTimeAnchor(RillTimeAnchorType.Latest); + } + public static relative(grain: RillTimeGrain) { + return new RillTimeAnchor(RillTimeAnchorType.Relative).withGrain(grain); + } + public static absolute(time: string) { + return new RillTimeAnchor(RillTimeAnchorType.Custom).withAbsolute(time); + } + + public withGrain(grain: RillTimeGrain) { + this.grain = grain; + return this; + } + + public withOffset(grain: RillTimeGrain) { + this.offset = grain; + return this; + } + + public withAbsolute(time: string) { + this.absolute = time; + return this; + } + + public withTruncate(truncate: RillTimeGrain) { + this.truncate = truncate; + return this; + } + + public getLabel() { + const grain = this.grain ?? this.truncate; + if (!grain) { + return RillTimeAnchorType.Earliest.toString(); + } + + const unit = GrainToUnit[grain.grain]; + if (!unit) return InvalidTime; + + if (grain.count === 0) { + if (unit === "day") return "today"; + return `${unit} to date`; + } + + if (grain.count > 0) return InvalidTime; + + if (grain.count === -1) { + return `previous ${unit}`; + } + return `last ${-grain.count} ${unit}s`; + } + + public getType() { + const grain = this.grain ?? this.truncate; + if (!grain || grain.count > 0) { + return RillTimeType.Unknown; + } + + if (grain.count === 0) { + return RillTimeType.PeriodToDate; + } + if (grain.count === -1) { + return RillTimeType.PreviousPeriod; + } + return RillTimeType.Latest; + } +} + +export type RillTimeGrain = { + grain: string; + count: number; +}; +export type RillTimeRangeGrain = { + grain: string; + isComplete: boolean; +}; + +export type RillTimeRangeModifier = { + timeZone: string | undefined; + at: RillTimeAnchor | undefined; +}; + +function capitalizeFirstChar(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/web-common/src/features/dashboards/url-state/time-ranges/parser.ts b/web-common/src/features/dashboards/url-state/time-ranges/parser.ts new file mode 100644 index 00000000000..b90660bfa47 --- /dev/null +++ b/web-common/src/features/dashboards/url-state/time-ranges/parser.ts @@ -0,0 +1,22 @@ +import type { RillTime } from "@rilldata/web-common/features/dashboards/url-state/time-ranges/RillTime"; +import grammar from "./rill-time.cjs"; +import nearley from "nearley"; + +const compiledGrammar = nearley.Grammar.fromCompiled(grammar); +export function parseRillTime(rillTimeRange: string): RillTime { + const parser = new nearley.Parser(compiledGrammar); + parser.feed(rillTimeRange); + const rt = parser.results[0] as RillTime; + rt.timeRange = rillTimeRange; + return rt; +} + +export function validateRillTime(rillTime: string): Error | undefined { + try { + const parser = parseRillTime(rillTime); + if (!parser) return new Error("Unknown error"); + } catch (err) { + return err; + } + return undefined; +} diff --git a/web-common/src/features/dashboards/url-state/time-ranges/rill-time.cjs b/web-common/src/features/dashboards/url-state/time-ranges/rill-time.cjs new file mode 100644 index 00000000000..876131eff8c --- /dev/null +++ b/web-common/src/features/dashboards/url-state/time-ranges/rill-time.cjs @@ -0,0 +1,165 @@ +// Generated automatically by nearley, version 2.20.1 +// http://github.com/Hardmath123/nearley +function id(x) { return x[0]; } + + import { + RillTimeAnchor, + RillTime, + } from "./RillTime.ts" +let Lexer = undefined; +let ParserRules = [ + {"name": "_$ebnf$1", "symbols": []}, + {"name": "_$ebnf$1", "symbols": ["_$ebnf$1", "wschar"], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "_", "symbols": ["_$ebnf$1"], "postprocess": function(d) {return null;}}, + {"name": "__$ebnf$1", "symbols": ["wschar"]}, + {"name": "__$ebnf$1", "symbols": ["__$ebnf$1", "wschar"], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "__", "symbols": ["__$ebnf$1"], "postprocess": function(d) {return null;}}, + {"name": "wschar", "symbols": [/[ \t\n\v\f]/], "postprocess": id}, + {"name": "unsigned_int$ebnf$1", "symbols": [/[0-9]/]}, + {"name": "unsigned_int$ebnf$1", "symbols": ["unsigned_int$ebnf$1", /[0-9]/], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "unsigned_int", "symbols": ["unsigned_int$ebnf$1"], "postprocess": + function(d) { + return parseInt(d[0].join("")); + } + }, + {"name": "int$ebnf$1$subexpression$1", "symbols": [{"literal":"-"}]}, + {"name": "int$ebnf$1$subexpression$1", "symbols": [{"literal":"+"}]}, + {"name": "int$ebnf$1", "symbols": ["int$ebnf$1$subexpression$1"], "postprocess": id}, + {"name": "int$ebnf$1", "symbols": [], "postprocess": function(d) {return null;}}, + {"name": "int$ebnf$2", "symbols": [/[0-9]/]}, + {"name": "int$ebnf$2", "symbols": ["int$ebnf$2", /[0-9]/], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "int", "symbols": ["int$ebnf$1", "int$ebnf$2"], "postprocess": + function(d) { + if (d[0]) { + return parseInt(d[0][0]+d[1].join("")); + } else { + return parseInt(d[1].join("")); + } + } + }, + {"name": "unsigned_decimal$ebnf$1", "symbols": [/[0-9]/]}, + {"name": "unsigned_decimal$ebnf$1", "symbols": ["unsigned_decimal$ebnf$1", /[0-9]/], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "unsigned_decimal$ebnf$2$subexpression$1$ebnf$1", "symbols": [/[0-9]/]}, + {"name": "unsigned_decimal$ebnf$2$subexpression$1$ebnf$1", "symbols": ["unsigned_decimal$ebnf$2$subexpression$1$ebnf$1", /[0-9]/], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "unsigned_decimal$ebnf$2$subexpression$1", "symbols": [{"literal":"."}, "unsigned_decimal$ebnf$2$subexpression$1$ebnf$1"]}, + {"name": "unsigned_decimal$ebnf$2", "symbols": ["unsigned_decimal$ebnf$2$subexpression$1"], "postprocess": id}, + {"name": "unsigned_decimal$ebnf$2", "symbols": [], "postprocess": function(d) {return null;}}, + {"name": "unsigned_decimal", "symbols": ["unsigned_decimal$ebnf$1", "unsigned_decimal$ebnf$2"], "postprocess": + function(d) { + return parseFloat( + d[0].join("") + + (d[1] ? "."+d[1][1].join("") : "") + ); + } + }, + {"name": "decimal$ebnf$1", "symbols": [{"literal":"-"}], "postprocess": id}, + {"name": "decimal$ebnf$1", "symbols": [], "postprocess": function(d) {return null;}}, + {"name": "decimal$ebnf$2", "symbols": [/[0-9]/]}, + {"name": "decimal$ebnf$2", "symbols": ["decimal$ebnf$2", /[0-9]/], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "decimal$ebnf$3$subexpression$1$ebnf$1", "symbols": [/[0-9]/]}, + {"name": "decimal$ebnf$3$subexpression$1$ebnf$1", "symbols": ["decimal$ebnf$3$subexpression$1$ebnf$1", /[0-9]/], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "decimal$ebnf$3$subexpression$1", "symbols": [{"literal":"."}, "decimal$ebnf$3$subexpression$1$ebnf$1"]}, + {"name": "decimal$ebnf$3", "symbols": ["decimal$ebnf$3$subexpression$1"], "postprocess": id}, + {"name": "decimal$ebnf$3", "symbols": [], "postprocess": function(d) {return null;}}, + {"name": "decimal", "symbols": ["decimal$ebnf$1", "decimal$ebnf$2", "decimal$ebnf$3"], "postprocess": + function(d) { + return parseFloat( + (d[0] || "") + + d[1].join("") + + (d[2] ? "."+d[2][1].join("") : "") + ); + } + }, + {"name": "percentage", "symbols": ["decimal", {"literal":"%"}], "postprocess": + function(d) { + return d[0]/100; + } + }, + {"name": "jsonfloat$ebnf$1", "symbols": [{"literal":"-"}], "postprocess": id}, + {"name": "jsonfloat$ebnf$1", "symbols": [], "postprocess": function(d) {return null;}}, + {"name": "jsonfloat$ebnf$2", "symbols": [/[0-9]/]}, + {"name": "jsonfloat$ebnf$2", "symbols": ["jsonfloat$ebnf$2", /[0-9]/], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "jsonfloat$ebnf$3$subexpression$1$ebnf$1", "symbols": [/[0-9]/]}, + {"name": "jsonfloat$ebnf$3$subexpression$1$ebnf$1", "symbols": ["jsonfloat$ebnf$3$subexpression$1$ebnf$1", /[0-9]/], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "jsonfloat$ebnf$3$subexpression$1", "symbols": [{"literal":"."}, "jsonfloat$ebnf$3$subexpression$1$ebnf$1"]}, + {"name": "jsonfloat$ebnf$3", "symbols": ["jsonfloat$ebnf$3$subexpression$1"], "postprocess": id}, + {"name": "jsonfloat$ebnf$3", "symbols": [], "postprocess": function(d) {return null;}}, + {"name": "jsonfloat$ebnf$4$subexpression$1$ebnf$1", "symbols": [/[+-]/], "postprocess": id}, + {"name": "jsonfloat$ebnf$4$subexpression$1$ebnf$1", "symbols": [], "postprocess": function(d) {return null;}}, + {"name": "jsonfloat$ebnf$4$subexpression$1$ebnf$2", "symbols": [/[0-9]/]}, + {"name": "jsonfloat$ebnf$4$subexpression$1$ebnf$2", "symbols": ["jsonfloat$ebnf$4$subexpression$1$ebnf$2", /[0-9]/], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "jsonfloat$ebnf$4$subexpression$1", "symbols": [/[eE]/, "jsonfloat$ebnf$4$subexpression$1$ebnf$1", "jsonfloat$ebnf$4$subexpression$1$ebnf$2"]}, + {"name": "jsonfloat$ebnf$4", "symbols": ["jsonfloat$ebnf$4$subexpression$1"], "postprocess": id}, + {"name": "jsonfloat$ebnf$4", "symbols": [], "postprocess": function(d) {return null;}}, + {"name": "jsonfloat", "symbols": ["jsonfloat$ebnf$1", "jsonfloat$ebnf$2", "jsonfloat$ebnf$3", "jsonfloat$ebnf$4"], "postprocess": + function(d) { + return parseFloat( + (d[0] || "") + + d[1].join("") + + (d[2] ? "."+d[2][1].join("") : "") + + (d[3] ? "e" + (d[3][1] || "+") + d[3][2].join("") : "") + ); + } + }, + {"name": "dqstring$ebnf$1", "symbols": []}, + {"name": "dqstring$ebnf$1", "symbols": ["dqstring$ebnf$1", "dstrchar"], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "dqstring", "symbols": [{"literal":"\""}, "dqstring$ebnf$1", {"literal":"\""}], "postprocess": function(d) {return d[1].join(""); }}, + {"name": "sqstring$ebnf$1", "symbols": []}, + {"name": "sqstring$ebnf$1", "symbols": ["sqstring$ebnf$1", "sstrchar"], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "sqstring", "symbols": [{"literal":"'"}, "sqstring$ebnf$1", {"literal":"'"}], "postprocess": function(d) {return d[1].join(""); }}, + {"name": "btstring$ebnf$1", "symbols": []}, + {"name": "btstring$ebnf$1", "symbols": ["btstring$ebnf$1", /[^`]/], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "btstring", "symbols": [{"literal":"`"}, "btstring$ebnf$1", {"literal":"`"}], "postprocess": function(d) {return d[1].join(""); }}, + {"name": "dstrchar", "symbols": [/[^\\"\n]/], "postprocess": id}, + {"name": "dstrchar", "symbols": [{"literal":"\\"}, "strescape"], "postprocess": + function(d) { + return JSON.parse("\""+d.join("")+"\""); + } + }, + {"name": "sstrchar", "symbols": [/[^\\'\n]/], "postprocess": id}, + {"name": "sstrchar", "symbols": [{"literal":"\\"}, "strescape"], "postprocess": function(d) { return JSON.parse("\""+d.join("")+"\""); }}, + {"name": "sstrchar$string$1", "symbols": [{"literal":"\\"}, {"literal":"'"}], "postprocess": function joiner(d) {return d.join('');}}, + {"name": "sstrchar", "symbols": ["sstrchar$string$1"], "postprocess": function(d) {return "'"; }}, + {"name": "strescape", "symbols": [/["\\/bfnrt]/], "postprocess": id}, + {"name": "strescape", "symbols": [{"literal":"u"}, /[a-fA-F0-9]/, /[a-fA-F0-9]/, /[a-fA-F0-9]/, /[a-fA-F0-9]/], "postprocess": + function(d) { + return d.join(""); + } + }, + {"name": "rill_time", "symbols": ["time_anchor_part"], "postprocess": ([{ start, end }]) => new RillTime(start, end)}, + {"name": "rill_time", "symbols": ["time_anchor_part", "_", "grain_and_at_part"], "postprocess": ([{ start, end }, , { grain, modifier }]) => new RillTime(start, end, grain, modifier)}, + {"name": "time_anchor_part", "symbols": ["time_anchor", "_", {"literal":","}, "_", "time_anchor"], "postprocess": ([start, , , , end]) => ({ start, end })}, + {"name": "time_anchor_part", "symbols": ["time_anchor"], "postprocess": ([start]) => ({ start })}, + {"name": "time_anchor", "symbols": ["time_anchor_offset", "_", "time_anchor_offset", "_", {"literal":"/"}, "_", "grain"], "postprocess": ([mod, , offset, , , , truncate]) => mod.withOffset(offset).withTruncate(truncate)}, + {"name": "time_anchor", "symbols": ["time_anchor_offset", "_", "time_anchor_offset"], "postprocess": ([rillTime, , offset]) => rillTime.withOffset(offset)}, + {"name": "time_anchor", "symbols": ["time_anchor_offset", "_", {"literal":"/"}, "_", "grain"], "postprocess": ([mod, , , , truncate]) => mod.withTruncate(truncate)}, + {"name": "time_anchor", "symbols": ["time_anchor_offset"], "postprocess": id}, + {"name": "time_anchor_offset$string$1", "symbols": [{"literal":"n"}, {"literal":"o"}, {"literal":"w"}], "postprocess": function joiner(d) {return d.join('');}}, + {"name": "time_anchor_offset", "symbols": ["time_anchor_offset$string$1"], "postprocess": () => RillTimeAnchor.now()}, + {"name": "time_anchor_offset$string$2", "symbols": [{"literal":"e"}, {"literal":"a"}, {"literal":"r"}, {"literal":"l"}, {"literal":"i"}, {"literal":"e"}, {"literal":"s"}, {"literal":"t"}], "postprocess": function joiner(d) {return d.join('');}}, + {"name": "time_anchor_offset", "symbols": ["time_anchor_offset$string$2"], "postprocess": () => RillTimeAnchor.earliest()}, + {"name": "time_anchor_offset$string$3", "symbols": [{"literal":"l"}, {"literal":"a"}, {"literal":"t"}, {"literal":"e"}, {"literal":"s"}, {"literal":"t"}], "postprocess": function joiner(d) {return d.join('');}}, + {"name": "time_anchor_offset", "symbols": ["time_anchor_offset$string$3"], "postprocess": () => RillTimeAnchor.latest()}, + {"name": "time_anchor_offset$string$4", "symbols": [{"literal":"w"}, {"literal":"a"}, {"literal":"t"}, {"literal":"e"}, {"literal":"r"}, {"literal":"m"}, {"literal":"a"}, {"literal":"r"}, {"literal":"k"}], "postprocess": function joiner(d) {return d.join('');}}, + {"name": "time_anchor_offset", "symbols": ["time_anchor_offset$string$4"], "postprocess": () => RillTimeAnchor.latest()}, + {"name": "time_anchor_offset", "symbols": ["abs_time"], "postprocess": ([absTime]) => RillTimeAnchor.absolute(absTime)}, + {"name": "time_anchor_offset", "symbols": ["grain_modifier"], "postprocess": ([grain]) => RillTimeAnchor.relative(grain)}, + {"name": "grain_and_at_part", "symbols": [{"literal":":"}, "_", "range_grain_modifier", "_", {"literal":"@"}, "_", "at_modifiers"], "postprocess": ([, , grain, , , , modifier]) => ({ grain, modifier })}, + {"name": "grain_and_at_part", "symbols": [{"literal":":"}, "_", "range_grain_modifier"], "postprocess": ([, , grain]) => ({ grain })}, + {"name": "grain_and_at_part", "symbols": [{"literal":"@"}, "_", "at_modifiers"], "postprocess": ([, , modifier]) => ({ modifier })}, + {"name": "range_grain_modifier", "symbols": ["grain"], "postprocess": ([grain]) => ({ grain, isComplete: false })}, + {"name": "range_grain_modifier", "symbols": [{"literal":"|"}, "_", "grain", "_", {"literal":"|"}], "postprocess": ([, ,grain]) => ({ grain, isComplete: true })}, + {"name": "at_modifiers", "symbols": ["grain_modifier"], "postprocess": ([grain]) => ({ at: RillTimeAnchor.relative(grain) })}, + {"name": "at_modifiers", "symbols": ["timezone_modifier"], "postprocess": ([timeZone]) => ({ timeZone })}, + {"name": "at_modifiers", "symbols": ["grain_modifier", "_", "timezone_modifier"], "postprocess": ([grain, , timeZone]) => ({ at: RillTimeAnchor.relative(grain), timeZone })}, + {"name": "grain_modifier", "symbols": ["grain"], "postprocess": ([grain]) => ({ count: 0, grain })}, + {"name": "grain_modifier", "symbols": ["int", "grain"], "postprocess": ([count, grain]) => ({ count, grain })}, + {"name": "abs_time", "symbols": [/[\d]/, /[\d]/, /[\d]/, /[\d]/, /[\-]/, /[\d]/, /[\d]/, /[\-]/, /[\d]/, /[\d]/, "_", /[\d]/, /[\d]/, /[:]/, /[\d]/, /[\d]/], "postprocess": (args) => args.join("")}, + {"name": "abs_time", "symbols": [/[\d]/, /[\d]/, /[\d]/, /[\d]/, /[\-]/, /[\d]/, /[\d]/, /[\-]/, /[\d]/, /[\d]/], "postprocess": (args) => args.join("")}, + {"name": "timezone_modifier$ebnf$1", "symbols": [/[^}]/]}, + {"name": "timezone_modifier$ebnf$1", "symbols": ["timezone_modifier$ebnf$1", /[^}]/], "postprocess": function arrpush(d) {return d[0].concat([d[1]]);}}, + {"name": "timezone_modifier", "symbols": [{"literal":"{"}, "_", "timezone_modifier$ebnf$1", "_", {"literal":"}"}], "postprocess": ([, , tz]) => tz.join("")}, + {"name": "grain", "symbols": [/[smhdDWQMY]/], "postprocess": id} +]; +let ParserStart = "rill_time"; +export default { Lexer, ParserRules, ParserStart }; diff --git a/web-common/src/features/dashboards/url-state/time-ranges/rill-time.ne b/web-common/src/features/dashboards/url-state/time-ranges/rill-time.ne new file mode 100644 index 00000000000..c62c91286b4 --- /dev/null +++ b/web-common/src/features/dashboards/url-state/time-ranges/rill-time.ne @@ -0,0 +1,51 @@ +@preprocessor esmodule +@builtin "whitespace.ne" +@builtin "number.ne" +@builtin "string.ne" + +@{% + import { + RillTimeAnchor, + RillTime, + } from "./RillTime.ts" +%} + +rill_time => time_anchor_part {% ([{ start, end }]) => new RillTime(start, end) %} + | time_anchor_part _ grain_and_at_part {% ([{ start, end }, , { grain, modifier }]) => new RillTime(start, end, grain, modifier) %} + +time_anchor_part => time_anchor _ "," _ time_anchor {% ([start, , , , end]) => ({ start, end }) %} + | time_anchor {% ([start]) => ({ start }) %} + +time_anchor => time_anchor_offset _ time_anchor_offset _ "/" _ grain {% ([mod, , offset, , , , truncate]) => mod.withOffset(offset).withTruncate(truncate) %} + | time_anchor_offset _ time_anchor_offset {% ([rillTime, , offset]) => rillTime.withOffset(offset) %} + | time_anchor_offset _ "/" _ grain {% ([mod, , , , truncate]) => mod.withTruncate(truncate) %} + | time_anchor_offset {% id %} + + +time_anchor_offset => "now" {% () => RillTimeAnchor.now() %} + | "earliest" {% () => RillTimeAnchor.earliest() %} + | "latest" {% () => RillTimeAnchor.latest() %} + | "watermark" {% () => RillTimeAnchor.latest() %} + | abs_time {% ([absTime]) => RillTimeAnchor.absolute(absTime) %} + | grain_modifier {% ([grain]) => RillTimeAnchor.relative(grain) %} + +grain_and_at_part => ":" _ range_grain_modifier _ "@" _ at_modifiers {% ([, , grain, , , , modifier]) => ({ grain, modifier }) %} + | ":" _ range_grain_modifier {% ([, , grain]) => ({ grain }) %} + | "@" _ at_modifiers {% ([, , modifier]) => ({ modifier }) %} + +range_grain_modifier => grain {% ([grain]) => ({ grain, isComplete: false }) %} + | "|" _ grain _ "|" {% ([, ,grain]) => ({ grain, isComplete: true }) %} + +at_modifiers => grain_modifier {% ([grain]) => ({ at: RillTimeAnchor.relative(grain) }) %} + | timezone_modifier {% ([timeZone]) => ({ timeZone }) %} + | grain_modifier _ timezone_modifier {% ([grain, , timeZone]) => ({ at: RillTimeAnchor.relative(grain), timeZone }) %} + +grain_modifier => grain {% ([grain]) => ({ count: 0, grain }) %} + | int grain {% ([count, grain]) => ({ count, grain }) %} + +abs_time => [\d] [\d] [\d] [\d] [\-] [\d] [\d] [\-] [\d] [\d] _ [\d] [\d] [:] [\d] [\d] {% (args) => args.join("") %} + | [\d] [\d] [\d] [\d] [\-] [\d] [\d] [\-] [\d] [\d] {% (args) => args.join("") %} + +timezone_modifier => "{" _ [^}]:+ _ "}" {% ([, , tz]) => tz.join("") %} + +grain => [smhdDWQMY] {% id %} diff --git a/web-common/src/features/dashboards/url-state/time-ranges/rill-time.spec.ts b/web-common/src/features/dashboards/url-state/time-ranges/rill-time.spec.ts new file mode 100644 index 00000000000..0cd983433c0 --- /dev/null +++ b/web-common/src/features/dashboards/url-state/time-ranges/rill-time.spec.ts @@ -0,0 +1,67 @@ +import { parseRillTime } from "@rilldata/web-common/features/dashboards/url-state/time-ranges/parser"; +import { describe, expect, it } from "vitest"; +import grammar from "./rill-time.cjs"; +import nearley from "nearley"; + +describe("rill time", () => { + describe("positive cases", () => { + const Cases: [rillTime: string, label: string][] = [ + ["m : |s|", "Minute to date, incomplete"], + ["-5m : |m|", "Last 5 minutes, incomplete"], + ["-5m, 0m : |m|", "Last 5 minutes"], + ["-7d, 0d : |h|", "Last 7 days"], + ["-7d, now/d : |h|", "Last 7 days"], + ["-6d, now : |h|", "Last 7 days, incomplete"], + ["-6d, now : h", "Last 7 days, incomplete"], + ["d : h", "Today, incomplete"], + + // TODO: correct label for the below + ["-7d, -5d : h", "-7d, -5d : h"], + ["-2d, now/d : h @ -5d", "-2d, now/d : h @ -5d"], + ["-2d, now/d @ -5d", "-2d, now/d @ -5d"], + + [ + "-7d, now/d : h @ {Asia/Kathmandu}", + "-7d, now/d : h @ {Asia/Kathmandu}", + ], + [ + "-7d, now/d : |h| @ {Asia/Kathmandu}", + "-7d, now/d : |h| @ {Asia/Kathmandu}", + ], + [ + "-7d, now/d : |h| @ -5d {Asia/Kathmandu}", + "-7d, now/d : |h| @ -5d {Asia/Kathmandu}", + ], + + // TODO: should these be something different when end is latest vs now? + ["-7d, latest/d : |h|", "Last 7 days"], + ["-6d, latest : |h|", "Last 6 days, incomplete"], + ["-6d, latest : h", "Last 6 days, incomplete"], + + ["2024-01-01, 2024-03-31", "2024-01-01, 2024-03-31"], + [ + "2024-01-01 10:00, 2024-03-31 18:00", + "2024-01-01 10:00, 2024-03-31 18:00", + ], + + ["-7W+2d", "-7W+2d"], + ["-7W,latest+2d", "-7W,latest+2d"], + ["-7W,-7W+2d", "-7W,-7W+2d"], + ["-7W+2d", "-7W+2d"], + ["2024-01-01-1W,2024-01-01", "2024-01-01-1W,2024-01-01"], + ]; + + const compiledGrammar = nearley.Grammar.fromCompiled(grammar); + for (const [rillTime, label] of Cases) { + it(rillTime, () => { + const parser = new nearley.Parser(compiledGrammar); + parser.feed(rillTime); + // assert that there is only match. this ensures unambiguous grammar. + expect(parser.results).length(1); + + const rt = parseRillTime(rillTime); + expect(rt.getLabel()).toEqual(label); + }); + } + }); +}); diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte index f03dde5122e..d5439b0fb3c 100644 --- a/web-common/src/features/dashboards/workspace/Dashboard.svelte +++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte @@ -70,8 +70,6 @@ $: hidePivot = isEmbedded && $explore.data?.explore?.embedsHidePivot; $: ({ - timeStart: start, - timeEnd: end, showTimeComparison, comparisonTimeStart, comparisonTimeEnd, @@ -79,8 +77,8 @@ } = $timeControlsStore); $: timeRange = { - start, - end, + start: $dashboardStore?.selectedTimeRange?.start?.toISOString(), + end: $dashboardStore?.selectedTimeRange?.end?.toISOString(), }; $: comparisonTimeRange = showTimeComparison diff --git a/web-common/src/features/explores/selectors.ts b/web-common/src/features/explores/selectors.ts index 902113902ee..4326b9671ba 100644 --- a/web-common/src/features/explores/selectors.ts +++ b/web-common/src/features/explores/selectors.ts @@ -1,5 +1,6 @@ import type { CreateQueryOptions, QueryFunction } from "@rilldata/svelte-query"; import type { MetricsExplorerEntity } from "@rilldata/web-common/features/dashboards/stores/metrics-explorer-entity"; +import { fetchTimeRanges } from "@rilldata/web-common/features/dashboards/time-controls/time-ranges"; import { convertPresetToExploreState, convertURLToExploreState, @@ -21,6 +22,7 @@ import { type V1ExplorePreset, getQueryServiceMetricsViewSchemaQueryKey, queryServiceMetricsViewSchema, + type V1TimeRange, } from "@rilldata/web-common/runtime-client"; import type { ErrorType } from "@rilldata/web-common/runtime-client/http-client"; import { error } from "@sveltejs/kit"; @@ -129,6 +131,11 @@ export async function fetchExploreSpec( }); } + let timeRanges: V1TimeRange[] = []; + if (metricsViewSpec.timeDimension) { + timeRanges = await fetchTimeRanges(exploreSpec); + } + const defaultExplorePreset = getDefaultExplorePreset( exploreSpec, fullTimeRange, @@ -138,11 +145,13 @@ export async function fetchExploreSpec( metricsViewSpec, exploreSpec, defaultExplorePreset, + timeRanges, ); return { explore: exploreResource, metricsView: metricsViewResource, + timeRanges, defaultExplorePreset, exploreStateFromYAMLConfig, errors, @@ -170,6 +179,7 @@ export function getExploreStates( metricsViewSpec: V1MetricsViewSpec | undefined, exploreSpec: V1ExploreSpec | undefined, defaultExplorePreset: V1ExplorePreset, + timeRanges: V1TimeRange[], ) { if (!metricsViewSpec || !exploreSpec) { return { @@ -185,6 +195,7 @@ export function getExploreStates( metricsViewSpec, exploreSpec, defaultExplorePreset, + timeRanges, ); const { exploreStateFromSessionStorage, errors: errorsFromLoad } = diff --git a/web-common/src/lib/arrayUtils.ts b/web-common/src/lib/arrayUtils.ts index 0a6caf14ec4..aa0824d1d54 100644 --- a/web-common/src/lib/arrayUtils.ts +++ b/web-common/src/lib/arrayUtils.ts @@ -47,3 +47,8 @@ export function arrayOrderedEquals(src: T[], tar: T[]) { export function getMissingValues(src: T[], tar: T[]) { return tar.filter((v) => !src.includes(v)); } + +export function dedupe(array: T[]): T[] { + const set = new Set(array); + return [...set.values()]; +} diff --git a/web-common/src/runtime-client/gen/query-service/query-service.ts b/web-common/src/runtime-client/gen/query-service/query-service.ts index 73e1d1e1ef8..9ce477d1b05 100644 --- a/web-common/src/runtime-client/gen/query-service/query-service.ts +++ b/web-common/src/runtime-client/gen/query-service/query-service.ts @@ -1102,64 +1102,86 @@ export const queryServiceMetricsViewTimeRanges = ( instanceId: string, metricsViewName: string, queryServiceMetricsViewTimeRangesBody: QueryServiceMetricsViewTimeRangesBody, + signal?: AbortSignal, ) => { return httpClient({ url: `/v1/instances/${instanceId}/queries/metrics-views/${metricsViewName}/time-ranges`, method: "post", headers: { "Content-Type": "application/json" }, data: queryServiceMetricsViewTimeRangesBody, + signal, }); }; -export type QueryServiceMetricsViewTimeRangesMutationResult = NonNullable< +export const getQueryServiceMetricsViewTimeRangesQueryKey = ( + instanceId: string, + metricsViewName: string, + queryServiceMetricsViewTimeRangesBody: QueryServiceMetricsViewTimeRangesBody, +) => [ + `/v1/instances/${instanceId}/queries/metrics-views/${metricsViewName}/time-ranges`, + queryServiceMetricsViewTimeRangesBody, +]; + +export type QueryServiceMetricsViewTimeRangesQueryResult = NonNullable< Awaited> >; -export type QueryServiceMetricsViewTimeRangesMutationBody = - QueryServiceMetricsViewTimeRangesBody; -export type QueryServiceMetricsViewTimeRangesMutationError = - ErrorType; +export type QueryServiceMetricsViewTimeRangesQueryError = ErrorType; export const createQueryServiceMetricsViewTimeRanges = < + TData = Awaited>, TError = ErrorType, - TContext = unknown, ->(options?: { - mutation?: CreateMutationOptions< - Awaited>, - TError, - { - instanceId: string; - metricsViewName: string; - data: QueryServiceMetricsViewTimeRangesBody; - }, - TContext - >; -}) => { - const { mutation: mutationOptions } = options ?? {}; +>( + instanceId: string, + metricsViewName: string, + queryServiceMetricsViewTimeRangesBody: QueryServiceMetricsViewTimeRangesBody, + options?: { + query?: CreateQueryOptions< + Awaited>, + TError, + TData + >; + }, +): CreateQueryResult & { + queryKey: QueryKey; +} => { + const { query: queryOptions } = options ?? {}; - const mutationFn: MutationFunction< - Awaited>, - { - instanceId: string; - metricsViewName: string; - data: QueryServiceMetricsViewTimeRangesBody; - } - > = (props) => { - const { instanceId, metricsViewName, data } = props ?? {}; + const queryKey = + queryOptions?.queryKey ?? + getQueryServiceMetricsViewTimeRangesQueryKey( + instanceId, + metricsViewName, + queryServiceMetricsViewTimeRangesBody, + ); - return queryServiceMetricsViewTimeRanges(instanceId, metricsViewName, data); - }; + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + queryServiceMetricsViewTimeRanges( + instanceId, + metricsViewName, + queryServiceMetricsViewTimeRangesBody, + signal, + ); - return createMutation< + const query = createQuery< Awaited>, TError, - { - instanceId: string; - metricsViewName: string; - data: QueryServiceMetricsViewTimeRangesBody; - }, - TContext - >(mutationFn, mutationOptions); + TData + >({ + queryKey, + queryFn, + enabled: !!(instanceId && metricsViewName), + ...queryOptions, + }) as CreateQueryResult & { + queryKey: QueryKey; + }; + + query.queryKey = queryKey; + + return query; }; + /** * @summary MetricsViewTimeSeries returns time series for the measures in the metrics view. It's a convenience API for querying a metrics view. diff --git a/web-local/src/routes/(viz)/explore/[name]/+layout.ts b/web-local/src/routes/(viz)/explore/[name]/+layout.ts index 71007aa784a..b786a0df5ee 100644 --- a/web-local/src/routes/(viz)/explore/[name]/+layout.ts +++ b/web-local/src/routes/(viz)/explore/[name]/+layout.ts @@ -14,6 +14,7 @@ export const load = async ({ params, depends }) => { const { explore, metricsView, + timeRanges, defaultExplorePreset, exploreStateFromYAMLConfig, } = await fetchExploreSpec(instanceId, exploreName); @@ -21,6 +22,7 @@ export const load = async ({ params, depends }) => { return { explore, metricsView, + timeRanges, defaultExplorePreset, exploreStateFromYAMLConfig, }; diff --git a/web-local/src/routes/(viz)/explore/[name]/+page.ts b/web-local/src/routes/(viz)/explore/[name]/+page.ts index 14c51243b95..9fc83accf7d 100644 --- a/web-local/src/routes/(viz)/explore/[name]/+page.ts +++ b/web-local/src/routes/(viz)/explore/[name]/+page.ts @@ -1,7 +1,8 @@ import { getExploreStates } from "@rilldata/web-common/features/explores/selectors"; export const load = async ({ url, parent, params }) => { - const { explore, metricsView, defaultExplorePreset } = await parent(); + const { explore, metricsView, timeRanges, defaultExplorePreset } = + await parent(); const { name: exploreName } = params; const metricsViewSpec = metricsView.metricsView?.state?.validSpec; const exploreSpec = explore.explore?.state?.validSpec; @@ -15,6 +16,7 @@ export const load = async ({ url, parent, params }) => { metricsViewSpec, exploreSpec, defaultExplorePreset, + timeRanges, ), }; };