diff --git a/web-common/src/features/dashboards/providers/DashboardConfigProvider.svelte.ts b/web-common/src/features/dashboards/providers/DashboardConfigProvider.svelte.ts
new file mode 100644
index 000000000000..8193674a0eb9
--- /dev/null
+++ b/web-common/src/features/dashboards/providers/DashboardConfigProvider.svelte.ts
@@ -0,0 +1,100 @@
+import { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts";
+import { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts";
+import {
+ createQueryServiceResolveCanvas,
+ createRuntimeServiceGetExplore,
+} from "@rilldata/web-common/runtime-client";
+import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2";
+
+/**
+ * Metrics view name and other yaml config provider based on dashboard type.
+ */
+export class DashboardConfigProvider {
+ public readonly metricsViewsProvider: MetricsViewsProvider;
+ public readonly yamlConfigProvider: YAMLConfigProvider;
+ public defaultUrlParams: URLSearchParams = $state(new URLSearchParams());
+
+ public cleanup: (() => void) | undefined = undefined;
+
+ public constructor(runtimeClient: RuntimeClient) {
+ this.metricsViewsProvider = new MetricsViewsProvider(runtimeClient, []);
+ this.yamlConfigProvider = new YAMLConfigProvider();
+ }
+}
+
+export class ExploreDashboardConfigProvider extends DashboardConfigProvider {
+ public constructor(runtimeClient: RuntimeClient, exploreName: string) {
+ super(runtimeClient);
+
+ const getExploreQuery = createRuntimeServiceGetExplore(runtimeClient, {
+ name: exploreName,
+ });
+ const getExploreUnsub = getExploreQuery.subscribe((getExploreResp) => {
+ const metricsViewSpec =
+ getExploreResp.data?.metricsView?.metricsView?.state?.validSpec ?? {};
+ const exploreSpec =
+ getExploreResp.data?.explore?.explore?.state?.validSpec ?? {};
+
+ this.metricsViewsProvider.setMetricsViewNames(
+ exploreSpec.metricsView ? [exploreSpec.metricsView] : [],
+ );
+
+ this.yamlConfigProvider.update({
+ restrictedDimensions: exploreSpec.dimensions,
+ primaryTimeDimension: metricsViewSpec.timeDimension,
+ restrictedMeasures: exploreSpec.measures,
+
+ defaultTimeRange: exploreSpec.defaultPreset?.timeRange,
+ timeRanges: exploreSpec.timeRanges,
+ timeZones: exploreSpec.timeZones,
+ });
+ });
+
+ this.cleanup = () => {
+ getExploreUnsub();
+ this.metricsViewsProvider.cleanup();
+ this.yamlConfigProvider.cleanup?.();
+ };
+ }
+}
+
+export class CanvasDashboardConfigProvider extends DashboardConfigProvider {
+ public constructor(runtimeClient: RuntimeClient, canvasName: string) {
+ super(runtimeClient);
+
+ const resolveCanvasQuery = createQueryServiceResolveCanvas(runtimeClient, {
+ canvas: canvasName,
+ });
+ const resolveCanvasUnsub = resolveCanvasQuery.subscribe(
+ (resolveCanvasResp) => {
+ const canvasSpec =
+ resolveCanvasResp.data?.canvas?.canvas?.state?.validSpec ?? {};
+
+ this.metricsViewsProvider.setMetricsViewNames(
+ Object.keys(resolveCanvasResp.data?.referencedMetricsViews ?? {}),
+ );
+
+ const defaultFilters = Object.fromEntries(
+ Object.entries(canvasSpec.defaultPreset?.filterExpr ?? {}).map(
+ ([mv, sqlFilter]) => [mv, sqlFilter.expression],
+ ),
+ );
+ this.yamlConfigProvider.update({
+ defaultFilters,
+ pinnedFilters: canvasSpec.pinnedFilters,
+ requiredFilters: canvasSpec.requiredFilters,
+
+ defaultTimeRange: canvasSpec.defaultPreset?.timeRange,
+ timeRanges: canvasSpec.timeRanges,
+ timeZones: canvasSpec.timeZones,
+ });
+ },
+ );
+
+ this.cleanup = () => {
+ resolveCanvasUnsub();
+ this.metricsViewsProvider.cleanup();
+ this.yamlConfigProvider.cleanup?.();
+ };
+ }
+}
diff --git a/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts
new file mode 100644
index 000000000000..617aea4262d8
--- /dev/null
+++ b/web-common/src/features/dashboards/providers/YAMLConfigProvider.svelte.ts
@@ -0,0 +1,97 @@
+import {
+ type V1ExploreTimeRange,
+ type V1Expression,
+} from "@rilldata/web-common/runtime-client";
+import { DEFAULT_TIMEZONES } from "@rilldata/web-common/lib/time/config.ts";
+import type { DateTime } from "luxon";
+
+/**
+ * A provider for YAML only configuration. These are only mutable during yaml editing.
+ */
+export class YAMLConfigProvider {
+ public defaultFilters = $state
>({});
+ public pinnedFilters = $state>({});
+ public specPinnedFilters = $state>({});
+ public requiredFilters = $state>({});
+ public specRequiredFilters = $state>({});
+
+ public restrictedDimensions = $state(undefined);
+ public primaryTimeDimension = $state(undefined);
+ public restrictedMeasures = $state(undefined);
+
+ public defaultTimeRange = $state(undefined);
+ public timeRanges = $state([]);
+ public timeZones = $state(DEFAULT_TIMEZONES);
+
+ public editable = $state(false);
+
+ public cleanup: (() => void) | undefined = undefined;
+
+ public update({
+ defaultFilters,
+ pinnedFilters,
+ requiredFilters,
+
+ restrictedDimensions,
+ primaryTimeDimension,
+ restrictedMeasures,
+
+ defaultTimeRange,
+ timeRanges,
+ timeZones,
+ }: {
+ defaultFilters?: YAMLConfigProvider["defaultFilters"];
+ pinnedFilters?: string[];
+ requiredFilters?: string[];
+
+ restrictedDimensions?: YAMLConfigProvider["restrictedDimensions"];
+ primaryTimeDimension?: YAMLConfigProvider["primaryTimeDimension"];
+ restrictedMeasures?: YAMLConfigProvider["restrictedMeasures"];
+
+ defaultTimeRange?: YAMLConfigProvider["defaultTimeRange"];
+ timeRanges?: YAMLConfigProvider["timeRanges"];
+ timeZones?: YAMLConfigProvider["timeZones"];
+ }) {
+ this.defaultFilters = defaultFilters ?? {};
+
+ const pinnedFiltersRec = Object.fromEntries(
+ pinnedFilters?.map((filter) => [filter, true]) ?? [],
+ );
+ this.pinnedFilters = { ...pinnedFiltersRec };
+ this.specPinnedFilters = { ...pinnedFiltersRec };
+
+ const requiredFiltersRec = Object.fromEntries(
+ requiredFilters?.map((filter) => [filter, true]) ?? [],
+ );
+ this.requiredFilters = { ...requiredFiltersRec };
+ this.specRequiredFilters = { ...requiredFiltersRec };
+
+ this.restrictedDimensions = restrictedDimensions;
+ this.primaryTimeDimension = primaryTimeDimension;
+ this.restrictedMeasures = restrictedMeasures;
+
+ this.defaultTimeRange = defaultTimeRange;
+ this.timeRanges = timeRanges ?? [];
+ this.timeZones = timeZones ?? [];
+ }
+
+ public setEditable(newEditable: boolean) {
+ this.editable = newEditable;
+ }
+
+ public togglePinnedFilter(filter: string) {
+ if (!this.pinnedFilters[filter]) {
+ this.pinnedFilters[filter] = true;
+ } else {
+ delete this.pinnedFilters[filter];
+ }
+ }
+
+ public toggleRequiredFilter(filter: string) {
+ if (!this.requiredFilters[filter]) {
+ this.requiredFilters[filter] = true;
+ } else {
+ delete this.requiredFilters[filter];
+ }
+ }
+}
diff --git a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts
index 7b1c3e3e1240..173a25ab6ed0 100644
--- a/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts
+++ b/web-common/src/features/dashboards/state-managers/loaders/DashboardStateSync.ts
@@ -294,6 +294,7 @@ export class DashboardStateSync {
this.updating = false;
}
+ log("URL", redirectUrl);
// If the url doesn't need to be changed further then we can skip the goto
if (redirectUrl.search === pageState.url.search) {
return;
@@ -349,6 +350,7 @@ export class DashboardStateSync {
);
}
+ log("GOTO", newUrl);
// If the state didnt result in a new url then skip goto.
// This avoids adding redundant urls to the history.
if (newUrl.search === pageState.url.search) {
@@ -362,3 +364,11 @@ export class DashboardStateSync {
}
}
}
+
+function log(label: string, toUrl: URL) {
+ const fromUrlSearch = get(page).url.search;
+ const areEqual = fromUrlSearch === toUrl.search;
+ console.log(
+ `[${label}] ${fromUrlSearch} =${areEqual ? "x" : "="}> ${toUrl.search}`,
+ );
+}
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 f9f9036cd15b..a7687cb55fb6 100644
--- a/web-common/src/features/dashboards/state-managers/state-managers.ts
+++ b/web-common/src/features/dashboards/state-managers/state-managers.ts
@@ -33,6 +33,11 @@ import {
contextColWidthDefaults,
type ContextColWidths,
} from "../leaderboard-context-column";
+import { TimeFilterManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeFilterManager.svelte.ts";
+import {
+ DashboardConfigProvider,
+ ExploreDashboardConfigProvider,
+} from "@rilldata/web-common/features/dashboards/providers/DashboardConfigProvider.svelte.ts";
export type StateManagers = {
runtimeClient: RuntimeClient;
@@ -65,6 +70,9 @@ export type StateManagers = {
*/
contextColumnWidths: Writable;
defaultExploreState: Readable;
+ dashboardConfigProvider: DashboardConfigProvider;
+ timeFilterManager: TimeFilterManager;
+ cleanup: () => void;
};
export const DEFAULT_STORE_KEY = Symbol("state-managers");
@@ -163,6 +171,17 @@ export function createStateManagers({
},
);
+ const dashboardConfigProvider = new ExploreDashboardConfigProvider(
+ runtimeClient,
+ exploreName,
+ );
+ const timeFilterManager = new TimeFilterManager(
+ runtimeClient,
+ dashboardConfigProvider.metricsViewsProvider,
+ dashboardConfigProvider.yamlConfigProvider,
+ true,
+ );
+
return {
runtimeClient,
metricsViewName: metricsViewNameStore,
@@ -191,5 +210,11 @@ export function createStateManagers({
}),
contextColumnWidths,
defaultExploreState,
+
+ dashboardConfigProvider,
+ timeFilterManager,
+ cleanup: () => {
+ dashboardConfigProvider.cleanup?.();
+ },
};
}
diff --git a/web-common/src/features/dashboards/stores/dashboard-stores.ts b/web-common/src/features/dashboards/stores/dashboard-stores.ts
index 6beecd0c54b3..bd1e053bb297 100644
--- a/web-common/src/features/dashboards/stores/dashboard-stores.ts
+++ b/web-common/src/features/dashboards/stores/dashboard-stores.ts
@@ -33,6 +33,7 @@ import {
type PivotMeasureFormatting,
type PivotTableMode,
} from "../pivot/types";
+import type { TimeFilterManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeFilterManager.svelte.ts";
export interface MetricsExplorerStoreType {
entities: Record;
@@ -237,6 +238,27 @@ const metricsViewReducers = {
});
},
+ syncTimeFilters(name: string, timeFilterManager: TimeFilterManager) {
+ if (!name) return;
+ updateMetricsExplorerByName(name, (exploreState) => {
+ exploreState.selectedTimeRange = {
+ name: timeFilterManager.timeRangeManager.timeRange,
+ interval: timeFilterManager.timeRangeManager.timeGrain,
+ } as any;
+ exploreState.showTimeComparison =
+ timeFilterManager.comparisonTimeRangeManager.showComparison;
+ exploreState.selectedComparisonTimeRange = {
+ name: timeFilterManager.comparisonTimeRangeManager.comparisonTimeRange,
+ start:
+ timeFilterManager.comparisonTimeRangeManager.interval?.start?.toJSDate() ??
+ new Date(),
+ end:
+ timeFilterManager.comparisonTimeRangeManager.interval?.end?.toJSDate() ??
+ new Date(),
+ };
+ });
+ },
+
setPivotMode(name: string, mode: boolean) {
updateMetricsExplorerByName(name, (exploreState) => {
if (mode) {
diff --git a/web-common/src/features/dashboards/time-controls/ComparisonTimeRangeManager.svelte.ts b/web-common/src/features/dashboards/time-controls/ComparisonTimeRangeManager.svelte.ts
new file mode 100644
index 000000000000..9bd97479dddf
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/ComparisonTimeRangeManager.svelte.ts
@@ -0,0 +1,192 @@
+import { TimeRangeManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeRangeManager.svelte.ts";
+import {
+ RillIsoInterval,
+ RillTime,
+} from "@rilldata/web-common/features/dashboards/url-state/time-ranges/RillTime.ts";
+import { parseRillTime } from "@rilldata/web-common/features/dashboards/url-state/time-ranges/parser.ts";
+import { type Interval } from "luxon";
+import {
+ getAvailableComparisonsForTimeRange,
+ getComparisonInterval,
+} from "@rilldata/web-common/lib/time/comparisons";
+import { TimeComparisonOption } from "@rilldata/web-common/lib/time/types.ts";
+import type { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts";
+import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts";
+import { copySubsetParams } from "@rilldata/web-common/lib/url-utils.ts";
+import { getAdjustedInterval } from "@rilldata/web-common/lib/time/ranges";
+
+type ComparisonTimeRangeOption = {
+ name: TimeComparisonOption;
+ key: number;
+ interval: Interval;
+};
+
+const ComparisonTimeRangeParams = new Set([
+ ExploreStateURLParams.ComparisonTimeRange,
+]);
+
+export class ComparisonTimeRangeManager {
+ public comparisonTimeRange = $state(undefined);
+ public showComparison = $state(false);
+ public interval = $state(undefined);
+ public adjustedInterval = $state(undefined);
+
+ public comparisonTimeRangeOptions: ComparisonTimeRangeOption[];
+
+ public parsedTime: RillTime | undefined;
+
+ public curStateParams = $state(new URLSearchParams());
+ public curSetParams = $state(new URLSearchParams());
+
+ public constructor(
+ private readonly yamlConfigProvider: YAMLConfigProvider,
+ private readonly timeRangeManager: TimeRangeManager,
+ private readonly allowCustomTimeRange: boolean,
+ ) {
+ this.comparisonTimeRangeOptions = $derived(
+ this.getComparisonTimeRangeOptions(),
+ );
+
+ this.parsedTime = $derived.by(() => {
+ if (!this.comparisonTimeRange) return undefined;
+ try {
+ return parseRillTime(this.comparisonTimeRange);
+ } catch {
+ return undefined;
+ }
+ });
+ }
+
+ public createListener() {
+ $effect(() => {
+ const newParams = new URLSearchParams();
+ this.applyFilterToParams(newParams);
+ if (newParams.toString() === this.curStateParams.toString()) return;
+ this.curStateParams = newParams;
+ });
+ }
+
+ public setUrlParams(searchParams: URLSearchParams) {
+ this.curSetParams = copySubsetParams(
+ searchParams,
+ ComparisonTimeRangeParams,
+ );
+
+ if (searchParams.has(ExploreStateURLParams.ComparisonTimeRange)) {
+ this.showComparison = true;
+ void this.onSelectComparisonRange(
+ searchParams.get(ExploreStateURLParams.ComparisonTimeRange)!,
+ );
+ } else {
+ this.showComparison = false;
+ this.comparisonTimeRange = undefined;
+ }
+ }
+
+ public applyFilterToParams(searchParams: URLSearchParams) {
+ if (this.showComparison && this.comparisonTimeRange) {
+ searchParams.set(
+ ExploreStateURLParams.ComparisonTimeRange,
+ this.comparisonTimeRange,
+ );
+ } else {
+ searchParams.delete(ExploreStateURLParams.ComparisonTimeRange);
+ }
+ }
+
+ public onSelectComparisonRange(range: string) {
+ // TODO: reassign when primary time range changes.
+
+ this.comparisonTimeRange = range;
+ if (!this.showComparison) {
+ this.interval = undefined;
+ return;
+ }
+
+ try {
+ const parsed = parseRillTime(range);
+ if (parsed.interval instanceof RillIsoInterval) {
+ // TODO
+ } else {
+ this.interval = getComparisonInterval(
+ this.timeRangeManager.interval,
+ range,
+ this.timeRangeManager.timeZone,
+ );
+ this.adjustedInterval = this.interval
+ ? getAdjustedInterval(
+ this.interval,
+ this.timeRangeManager.timeGrain,
+ this.timeRangeManager.timeZone,
+ )
+ : undefined;
+ }
+ } catch {
+ return undefined;
+ }
+ }
+
+ public onToggleShowComparison() {
+ this.showComparison = !this.showComparison;
+ }
+
+ private getComparisonTimeRangeOptions() {
+ // Type-safety
+ if (
+ !this.timeRangeManager.minDate ||
+ !this.timeRangeManager.maxDate ||
+ !this.timeRangeManager.timeRange ||
+ !this.timeRangeManager.interval?.isValid ||
+ !this.timeRangeManager.interval.start ||
+ !this.timeRangeManager.interval.end
+ )
+ return [];
+
+ let allOptions: TimeComparisonOption[];
+
+ const timeRange = this.yamlConfigProvider.timeRanges?.find(
+ (tr) => tr.range === this.timeRangeManager.timeRange,
+ );
+ if (timeRange?.comparisonTimeRanges?.length) {
+ allOptions =
+ timeRange.comparisonTimeRanges?.map(
+ (co) => co.offset as TimeComparisonOption,
+ ) ?? [];
+ if (this.allowCustomTimeRange)
+ allOptions.push(TimeComparisonOption.CUSTOM);
+ } else {
+ allOptions = [...Object.values(TimeComparisonOption)];
+ if (!this.allowCustomTimeRange) {
+ allOptions = allOptions.filter(
+ (o) => o !== TimeComparisonOption.CUSTOM,
+ );
+ }
+ }
+
+ const timeComparisonOptions = getAvailableComparisonsForTimeRange(
+ this.timeRangeManager.minDate.toJSDate(),
+ this.timeRangeManager.maxDate.toJSDate(),
+ this.timeRangeManager.interval.start.toJSDate(),
+ this.timeRangeManager.interval.end.toJSDate(),
+ allOptions,
+ this.timeRangeManager.timeZone,
+ );
+
+ return timeComparisonOptions
+ .map((co, i) => {
+ const comparisonTimeRange = getComparisonInterval(
+ this.timeRangeManager.interval,
+ co,
+ this.timeRangeManager.timeZone,
+ );
+
+ if (!comparisonTimeRange) return undefined;
+ return {
+ name: co,
+ key: i,
+ interval: comparisonTimeRange,
+ };
+ })
+ .filter(Boolean) as ComparisonTimeRangeOption[];
+ }
+}
diff --git a/web-common/src/features/dashboards/time-controls/ComparisonTimeRangePicker.svelte b/web-common/src/features/dashboards/time-controls/ComparisonTimeRangePicker.svelte
new file mode 100644
index 000000000000..8d8b14fb7a20
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/ComparisonTimeRangePicker.svelte
@@ -0,0 +1,211 @@
+
+
+
+
+ {#if timeGrain && interval}
+
{
+ showSelector = !!(
+ comparisonTimeRange === TimeComparisonOption.CUSTOM && showComparison
+ );
+ }}
+ >
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+
+
+
+
+ {#each comparisonTimeRangeOptions as option (option.name)}
+ {@const preset = TIME_COMPARISON[option.name]}
+ {@const selected = selectedLabel === option.name}
+ onSelectComparisonRange(option.name)}
+ >
+
+ {preset?.label || option.name}
+
+
+ {#if option.name === TimeComparisonOption.CONTIGUOUS && comparisonTimeRangeOptions.length > 2}
+
+ {/if}
+ {/each}
+ {#if allowCustomTimeRange}
+ {#if comparisonTimeRangeOptions.length}
+
+ {/if}
+
+ {
+ showSelector = !showSelector;
+ }}
+ >
+
+ {m.time_custom()}
+
+
+ {/if}
+
+ {#if showSelector}
+
+ {#if !interval || interval?.isValid}
+ (open = false)}
+ />
+ {/if}
+
+ {/if}
+
+
+
+
+
+ {/if}
+
+
+
diff --git a/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts b/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts
new file mode 100644
index 000000000000..7d202e1e5305
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/TimeFilterManager.svelte.ts
@@ -0,0 +1,58 @@
+import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2";
+import type { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts";
+import { TimeRangeManager } from "@rilldata/web-common/features/dashboards/time-controls/TimeRangeManager.svelte.ts";
+import { ComparisonTimeRangeManager } from "@rilldata/web-common/features/dashboards/time-controls/ComparisonTimeRangeManager.svelte.ts";
+import type { YAMLConfigProvider } from "@rilldata/web-common/features/dashboards/providers/YAMLConfigProvider.svelte.ts";
+import { copyParamsToTarget } from "@rilldata/web-common/lib/url-utils.ts";
+
+export class TimeFilterManager {
+ public timeRangeManager: TimeRangeManager;
+ public comparisonTimeRangeManager: ComparisonTimeRangeManager;
+
+ public curStateParams = $state(new URLSearchParams());
+ public curSetParams = $state(new URLSearchParams());
+
+ public constructor(
+ runtimeClient: RuntimeClient,
+ metricsViewsProvider: MetricsViewsProvider,
+ yamlConfigProvider: YAMLConfigProvider,
+ allowCustomTimeRange: boolean,
+ ) {
+ this.timeRangeManager = new TimeRangeManager(
+ runtimeClient,
+ metricsViewsProvider,
+ );
+ this.comparisonTimeRangeManager = new ComparisonTimeRangeManager(
+ yamlConfigProvider,
+ this.timeRangeManager,
+ allowCustomTimeRange,
+ );
+ }
+
+ public createListener() {
+ this.timeRangeManager.createListener();
+ this.comparisonTimeRangeManager.createListener();
+
+ $effect(() => {
+ const newParams = new URLSearchParams();
+ this.timeRangeManager.applyFilterToParams(newParams);
+ this.comparisonTimeRangeManager.applyFilterToParams(newParams);
+ if (newParams.toString() === this.curStateParams.toString()) return;
+ this.curStateParams = newParams;
+ });
+ }
+
+ public setUrlParams(urlParams: URLSearchParams) {
+ this.timeRangeManager.setUrlParams(urlParams);
+ this.comparisonTimeRangeManager.setUrlParams(urlParams);
+
+ const newSetParams = new URLSearchParams(
+ this.timeRangeManager.curSetParams,
+ );
+ copyParamsToTarget(
+ this.comparisonTimeRangeManager.curSetParams,
+ newSetParams,
+ );
+ this.curSetParams = newSetParams;
+ }
+}
diff --git a/web-common/src/features/dashboards/time-controls/TimeFilters.svelte b/web-common/src/features/dashboards/time-controls/TimeFilters.svelte
new file mode 100644
index 000000000000..edd03adaae97
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/TimeFilters.svelte
@@ -0,0 +1,128 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {#if !hidePan}
+
+
+ {/if}
+
+
+
+
+
+
+
+
diff --git a/web-common/src/features/dashboards/time-controls/TimeRangeManager.svelte.ts b/web-common/src/features/dashboards/time-controls/TimeRangeManager.svelte.ts
new file mode 100644
index 000000000000..ddc0eaa91045
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/TimeRangeManager.svelte.ts
@@ -0,0 +1,337 @@
+import type { MetricsViewsProvider } from "@rilldata/web-common/features/metrics-views/providers/MetricsViewsProvider.svelte.ts";
+import { type Interval, DateTime } from "luxon";
+import {
+ RillIsoInterval,
+ RillPeriodToGrainInterval,
+ RillTime,
+ RillTimeLabel,
+} from "@rilldata/web-common/features/dashboards/url-state/time-ranges/RillTime.ts";
+import { V1TimeGrain } from "@rilldata/web-common/runtime-client";
+import {
+ overrideRillTimeRef,
+ parseRillTime,
+} from "@rilldata/web-common/features/dashboards/url-state/time-ranges/parser.ts";
+import { getTruncationGrain } from "@rilldata/web-common/lib/time/rill-time-grains.ts";
+import {
+ allowedGrainsForInterval,
+ DateTimeUnitToV1TimeGrain,
+ getGrainOrder,
+ V1TimeGrainToDateTimeUnit,
+ V1TimeGrainToOrder,
+} from "@rilldata/web-common/lib/time/new-grains.ts";
+import {
+ constructAsOfString,
+ constructNewString,
+ deriveInterval,
+} from "@rilldata/web-common/features/dashboards/time-controls/new-time-controls.ts";
+import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.ts";
+import { invalidationForMetricsViewData } from "@rilldata/web-common/runtime-client/invalidation.ts";
+import { RuntimeClient } from "@rilldata/web-common/runtime-client/v2";
+import { ExploreStateURLParams } from "@rilldata/web-common/features/dashboards/url-state/url-params.ts";
+import { copySubsetParams } from "@rilldata/web-common/lib/url-utils.ts";
+import { getAdjustedInterval } from "@rilldata/web-common/lib/time/ranges";
+
+const DefaultTimeZone = "UTC";
+
+const TimeRangeParams = new Set([
+ ExploreStateURLParams.TimeRange,
+ ExploreStateURLParams.TimeGrain,
+ ExploreStateURLParams.TimeZone,
+ ExploreStateURLParams.TimeDimension,
+]);
+
+export class TimeRangeManager {
+ public timeRange = $state(undefined);
+ public timeGrain = $state(undefined);
+ public timeZone = $state(DefaultTimeZone);
+ public timeDimension = $state(undefined);
+ public interval = $state(undefined);
+ public adjustedInterval = $state(undefined);
+
+ public minDate: DateTime | undefined;
+ public maxDate: DateTime | undefined;
+
+ public parsedTime: RillTime | undefined;
+ public truncationGrain: V1TimeGrain | undefined;
+ public ref: RillTimeLabel | string | undefined;
+ public snapToEnd: boolean;
+
+ public curStateParams = $state(new URLSearchParams());
+ public curSetParams = $state(new URLSearchParams());
+
+ public constructor(
+ private readonly runtimeClient: RuntimeClient,
+ private readonly metricsViewsProvider: MetricsViewsProvider,
+ ) {
+ this.minDate = $derived.by(() => {
+ const minDate = this.metricsViewsProvider.timeRangeSummary?.min
+ ? DateTime.fromISO(this.metricsViewsProvider.timeRangeSummary.min)
+ : undefined;
+ if (!minDate?.isValid) return undefined;
+ return minDate;
+ });
+ this.maxDate = $derived.by(() => {
+ const maxDate = this.metricsViewsProvider.timeRangeSummary?.max
+ ? DateTime.fromISO(this.metricsViewsProvider.timeRangeSummary.max)
+ : undefined;
+ if (!maxDate?.isValid) return undefined;
+ return maxDate;
+ });
+
+ this.parsedTime = $derived.by(() => {
+ if (!this.timeRange) return undefined;
+ try {
+ return parseRillTime(this.timeRange);
+ } catch {
+ return undefined;
+ }
+ });
+ this.truncationGrain = $derived(getTruncationGrain(this.parsedTime));
+ this.ref = $derived(
+ this.parsedTime?.isOldFormat
+ ? RillTimeLabel.Latest
+ : this.parsedTime?.asOfLabel?.label,
+ );
+ this.snapToEnd = $derived(
+ this.parsedTime?.isOldFormat
+ ? true
+ : !!this.parsedTime?.asOfLabel?.offset,
+ );
+ }
+
+ public createListener() {
+ $effect(() => {
+ const newParams = new URLSearchParams();
+ this.applyFilterToParams(newParams);
+ if (newParams.toString() === this.curStateParams.toString()) return;
+ this.curStateParams = newParams;
+ });
+ }
+
+ public setUrlParams(searchParams: URLSearchParams) {
+ this.curSetParams = copySubsetParams(searchParams, TimeRangeParams);
+
+ this.timeGrain =
+ DateTimeUnitToV1TimeGrain[
+ searchParams.get(ExploreStateURLParams.TimeGrain)!
+ ] ?? undefined;
+
+ this.timeZone =
+ searchParams.get(ExploreStateURLParams.TimeZone) ?? DefaultTimeZone;
+
+ this.timeDimension =
+ searchParams.get(ExploreStateURLParams.TimeDimension) ?? undefined;
+
+ if (searchParams.has(ExploreStateURLParams.TimeRange)) {
+ void this.onSelectRange(
+ searchParams.get(ExploreStateURLParams.TimeRange)!,
+ true,
+ );
+ } else {
+ this.timeRange = undefined;
+ this.interval = undefined;
+ }
+ }
+
+ public applyFilterToParams(searchParams: URLSearchParams) {
+ if (this.timeRange) {
+ searchParams.set(ExploreStateURLParams.TimeRange, this.timeRange);
+ } else {
+ searchParams.delete(ExploreStateURLParams.TimeRange);
+ }
+
+ const mappedGrain = this.timeGrain
+ ? V1TimeGrainToDateTimeUnit[this.timeGrain]
+ : undefined;
+ if (mappedGrain) {
+ searchParams.set(ExploreStateURLParams.TimeGrain, mappedGrain);
+ } else {
+ searchParams.delete(ExploreStateURLParams.TimeGrain);
+ }
+
+ if (this.timeZone !== DefaultTimeZone) {
+ searchParams.set(ExploreStateURLParams.TimeZone, this.timeZone);
+ } else {
+ searchParams.delete(ExploreStateURLParams.TimeZone);
+ }
+
+ if (this.timeDimension) {
+ searchParams.set(ExploreStateURLParams.TimeDimension, this.timeDimension);
+ } else {
+ searchParams.delete(ExploreStateURLParams.TimeDimension);
+ }
+ }
+
+ public onSelectRange(range: string, ignoreSnap?: boolean) {
+ try {
+ const parsed = parseRillTime(range);
+
+ const isPeriodToDate =
+ parsed.interval instanceof RillPeriodToGrainInterval;
+
+ const rangeGrainOrder =
+ getGrainOrder(parsed.rangeGrain) - (isPeriodToDate ? 1 : 0);
+
+ const asOfGrainOrder = getGrainOrder(this.truncationGrain);
+
+ const shouldAppendAsOfString =
+ !parsed.asOfLabel && !(parsed.interval instanceof RillIsoInterval);
+
+ if (asOfGrainOrder > rangeGrainOrder && parsed.rangeGrain) {
+ this.truncationGrain = parsed.rangeGrain;
+ }
+
+ if (shouldAppendAsOfString) {
+ const hasAsOfClause = !!this.parsedTime?.asOfLabel;
+
+ const isTruncationGrainAllowed =
+ getGrainOrder(this.truncationGrain) >=
+ this.metricsViewsProvider.smallestGrainOrder;
+ const newAsOfString = constructAsOfString(
+ this.ref ?? RillTimeLabel.Latest,
+ ignoreSnap
+ ? undefined
+ : this.truncationGrain
+ ? isTruncationGrainAllowed
+ ? this.truncationGrain
+ : parsed.rangeGrain
+ : (this.metricsViewsProvider.smallestTimeGrain ??
+ V1TimeGrain.TIME_GRAIN_MINUTE),
+ hasAsOfClause || this.snapToEnd ? this.snapToEnd : true,
+ );
+
+ overrideRillTimeRef(parsed, newAsOfString);
+ }
+
+ return this.applyTimeRange(parsed.toString());
+ } catch {
+ // This function is called in a controlled manner and should not throw
+ }
+ }
+
+ public onSelectGrain(grain: V1TimeGrain | undefined) {
+ if (!this.timeRange) return;
+
+ const newString = constructNewString({
+ currentString: this.timeRange,
+ truncationGrain: grain === this.truncationGrain ? undefined : grain,
+ snapToEnd: grain === this.truncationGrain ? false : this.snapToEnd,
+ ref: this.ref,
+ });
+
+ return this.applyTimeRange(newString);
+ }
+
+ public onSelectZone(tz: string) {
+ this.timeZone = tz;
+ if (!this.timeRange || !this.parsedTime) return;
+
+ if (this.parsedTime.interval instanceof RillIsoInterval) {
+ // TODO
+ } else {
+ void this.applyTimeRange(this.timeRange, tz);
+ }
+ }
+
+ public onSelectAsOfOption(
+ ref: RillTimeLabel | string | undefined,
+ inclusive: boolean,
+ ) {
+ if (!this.timeRange) return;
+ const newString = constructNewString({
+ currentString: this.timeRange,
+ truncationGrain: this.truncationGrain,
+ snapToEnd: ref === "watermark" ? false : inclusive,
+ ref,
+ });
+
+ return this.applyTimeRange(newString);
+ }
+
+ public onSelectTimeDimension(timeDimension: string) {
+ this.timeDimension = timeDimension;
+ if (this.timeRange) void this.applyTimeRange(this.timeRange);
+ }
+
+ private async applyTimeRange(newTimeRange: string, tz = this.timeZone) {
+ // If we don't have a valid time range, early return
+ if (
+ !this.metricsViewsProvider.timeRangeSummary?.max ||
+ this.timeRange === newTimeRange
+ ) {
+ return;
+ }
+
+ // This should be returned by the API, but it is not yet implemented
+ const includesTimeZoneOffset = newTimeRange.includes("tz");
+
+ if (includesTimeZoneOffset) {
+ const timeZone = newTimeRange.match(/tz (.*)/)?.[1];
+
+ if (timeZone) this.timeZone = timeZone;
+ }
+
+ await queryClient.cancelQueries({
+ predicate: (query) =>
+ this.metricsViewsProvider.metricsViewNames.some((mvName) =>
+ invalidationForMetricsViewData(query, mvName),
+ ),
+ });
+
+ const promises = this.metricsViewsProvider.metricsViewNames.map(
+ (mvName) => {
+ return deriveInterval(
+ newTimeRange,
+ this.runtimeClient,
+ mvName,
+ tz ?? "UTC",
+ this.timeDimension,
+ // executionTime, // TODO
+ );
+ },
+ );
+ const intervals = await Promise.all(promises);
+ let latestInterval: Interval | undefined = undefined;
+ let smallestGrain: V1TimeGrain | undefined = undefined;
+ intervals.forEach(({ interval, grain }) => {
+ if (
+ interval?.isValid &&
+ interval.end &&
+ (!latestInterval || latestInterval.end < interval.end)
+ ) {
+ latestInterval = interval;
+ }
+
+ if (
+ grain &&
+ (!smallestGrain ||
+ V1TimeGrainToOrder[grain] < V1TimeGrainToOrder[smallestGrain])
+ ) {
+ smallestGrain = grain;
+ }
+ });
+ if (!latestInterval) return;
+
+ const allowedGrains = allowedGrainsForInterval(
+ latestInterval,
+ this.metricsViewsProvider.smallestTimeGrain ??
+ V1TimeGrain.TIME_GRAIN_MINUTE,
+ );
+
+ const finalGrain =
+ this.timeGrain && allowedGrains.includes(this.timeGrain)
+ ? this.timeGrain
+ : smallestGrain && allowedGrains.includes(smallestGrain)
+ ? smallestGrain
+ : allowedGrains[0];
+
+ this.interval = latestInterval;
+ this.adjustedInterval = getAdjustedInterval(
+ latestInterval,
+ finalGrain,
+ this.timeZone,
+ );
+ this.timeRange = newTimeRange;
+ this.timeGrain = finalGrain;
+ }
+}
diff --git a/web-common/src/features/dashboards/time-controls/TimeRangePicker.svelte b/web-common/src/features/dashboards/time-controls/TimeRangePicker.svelte
new file mode 100644
index 000000000000..c87a657cbd95
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/TimeRangePicker.svelte
@@ -0,0 +1,498 @@
+
+
+ {
+ if (e.metaKey && e.key === "k") {
+ open = !open;
+ }
+ }}
+/>
+
+ {
+ if (o) {
+ searchValue = timeString;
+ }
+ }}
+>
+
+
+ {#snippet child({ props: tooltipProps })}
+
+ {#snippet child({ props: popoverProps })}
+
+ {/snippet}
+
+ {/snippet}
+
+
+
+ {#if interval}
+
+ {/if}
+
+
+
+
+
+
+
+
+
+ {#if showDefaultItem && defaultTimeRange}
+
+ {/if}
+
+
+
+
+
+
+
+
void onSelectRange(r, true)}
+ />
+
+ {#if allTimeAllowed}
+
+
+
+ {/if}
+
+
+ {#if allowCustomTimeRange}
+
+
+
+
+ {/if}
+
+ {#if !lockTimeZone}
+
+
+
+
+ {
+ showCalendarPicker = false;
+ }}
+ class="group h-7 overflow-hidden hover:bg-popover-accent flex-none rounded-sm w-full select-none flex items-center truncate text-left gap-x-1 pr-1 pl-2"
+ >
+
+
+
+
+ {m.dashboard_time_zone()}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/if}
+
+ {#if showTimeDimensionSelector && timeDimensions.length > 1}
+
+
+
+
+ {
+ showCalendarPicker = false;
+ }}
+ aria-label={m.dashboard_select_time_axis()}
+ class="group h-7 overflow-hidden hover:bg-surface-hover flex-none rounded-sm w-full select-none flex items-center truncate text-left gap-x-1 pr-1 pl-2"
+ >
+
+
+
+ {m.dashboard_time_axis()}
+ {#if activeTimeDimension}
+
+
+
+ {/if}
+
+
+
+
+ {#each timeDimensions as { value, label, description } (value)}
+
+
+ {#snippet child({ props })}
+
+ {/snippet}
+
+ {#if description}
+
+ {label}
+ {description}
+
+ {/if}
+
+ {/each}
+
+
+
+ {/if}
+
+
+ {#if showCalendarPicker}
+
+ {
+ if (searchValue) onSelectRange(searchValue);
+ }}
+ updateRange={(string) => {
+ searchValue = string;
+ }}
+ closeMenu={() => (open = false)}
+ />
+
+ {/if}
+
+
+
+
+{#if dateTimeAnchor && !hideTruncationSelector}
+ timeRangeManager.onSelectGrain(g)}
+ onToggleAlignment={(inclusive) => {
+ timeRangeManager.onSelectAsOfOption(ref, inclusive);
+ }}
+ onSelectAsOfOption={(o) => {
+ timeRangeManager.onSelectAsOfOption(o, snapToEnd);
+ }}
+ />
+{/if}
+
+
diff --git a/web-common/src/features/dashboards/time-controls/time-filters-config.ts b/web-common/src/features/dashboards/time-controls/time-filters-config.ts
new file mode 100644
index 000000000000..2189946504d9
--- /dev/null
+++ b/web-common/src/features/dashboards/time-controls/time-filters-config.ts
@@ -0,0 +1,14 @@
+export type TimeFiltersConfig = {
+ hidePan?: boolean;
+ canPanLeft?: boolean;
+ canPanRight?: boolean;
+
+ showTimeDimensionSelector?: boolean;
+ allowCustomTimeRange?: boolean;
+ showDefaultItem: boolean;
+ lockTimeZone?: boolean;
+ showFullRange?: boolean;
+ showWatermark?: boolean;
+
+ side?: "top" | "right" | "bottom" | "left";
+};
diff --git a/web-common/src/features/dashboards/time-controls/time-range-utils.ts b/web-common/src/features/dashboards/time-controls/time-range-utils.ts
index 1bb325c70e2a..3b02f08ccca6 100644
--- a/web-common/src/features/dashboards/time-controls/time-range-utils.ts
+++ b/web-common/src/features/dashboards/time-controls/time-range-utils.ts
@@ -3,63 +3,14 @@
* this file should be deprecated in favor of the other time utils.
*
* */
-import type { TimeRange } from "@rilldata/web-common/lib/time/types";
-import { V1TimeGrain } from "@rilldata/web-common/runtime-client";
-import { TimeRangeName_DEPRECATE } from "./time-control-types";
-
+import {
+ type MetricsViewSpecDimension,
+ MetricsViewSpecDimensionType,
+ V1TimeGrain,
+} from "@rilldata/web-common/runtime-client";
import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config";
import { durationToMillis } from "@rilldata/web-common/lib/time/grains";
-// May not need this anymore as using TimeGrain objects
-export const supportedTimeGrainEnums = () => {
- const supportedEnums: string[] = [];
- const unsupportedTypes = [
- V1TimeGrain.TIME_GRAIN_UNSPECIFIED,
- V1TimeGrain.TIME_GRAIN_MILLISECOND,
- V1TimeGrain.TIME_GRAIN_SECOND,
- ];
-
- for (const timeGrain in V1TimeGrain) {
- if (unsupportedTypes.includes(V1TimeGrain[timeGrain])) {
- continue;
- }
- supportedEnums.push(timeGrain);
- }
-
- return supportedEnums;
-};
-
-// Moved to time range and renamed to isTimeRangeValidForMinTimeGrain
-export function isTimeRangeValidForTimeGrain(
- minTimeGrain: V1TimeGrain,
- timeRange: TimeRangeName_DEPRECATE,
-): boolean {
- const timeGrainEnums = Object.values(TIME_GRAIN).map(
- (timeGrain) => timeGrain.grain,
- );
- if (!timeGrainEnums.includes(minTimeGrain)) {
- return true;
- }
- if (!timeRange || timeRange === TimeRangeName_DEPRECATE.ALL_TIME) {
- return true;
- }
-
- const timeRangeDurationMs = getLastXTimeRangeDurationMs(timeRange);
-
- const allowedTimeGrains = getAllowedTimeGrains(timeRangeDurationMs);
- const maxAllowedTimeGrain = allowedTimeGrains[allowedTimeGrains.length - 1];
- return !isGrainBigger(minTimeGrain, maxAllowedTimeGrain);
-}
-
-// Moved to time-grain and renamed
-export function isGrainBigger(
- grain1: V1TimeGrain,
- grain2: V1TimeGrain,
-): boolean {
- if (grain1 === V1TimeGrain.TIME_GRAIN_UNSPECIFIED) return false;
- return getTimeGrainDurationMs(grain1) > getTimeGrainDurationMs(grain2);
-}
-
// Moved
export function getAllowedTimeGrains(timeRangeDurationMs) {
if (
@@ -142,97 +93,29 @@ export function getDefaultTimeGrain(start: Date, end: Date): V1TimeGrain {
}
}
-// Not needed
-export const timeGrainStringToEnum = (timeGrain: string): V1TimeGrain => {
- switch (timeGrain) {
- case "minute":
- return V1TimeGrain.TIME_GRAIN_MINUTE;
- case "hour":
- return V1TimeGrain.TIME_GRAIN_HOUR;
- case "day":
- return V1TimeGrain.TIME_GRAIN_DAY;
- case "week":
- return V1TimeGrain.TIME_GRAIN_WEEK;
- case "month":
- return V1TimeGrain.TIME_GRAIN_MONTH;
- case "year":
- return V1TimeGrain.TIME_GRAIN_YEAR;
- default:
- return V1TimeGrain.TIME_GRAIN_UNSPECIFIED;
- }
-};
-
-// Not needed
-export const timeGrainEnumToYamlString = (timeGrain: V1TimeGrain): string => {
- if (!timeGrain) return "";
- switch (timeGrain) {
- case V1TimeGrain.TIME_GRAIN_MINUTE:
- return "minute";
- case V1TimeGrain.TIME_GRAIN_HOUR:
- return "hour";
- case V1TimeGrain.TIME_GRAIN_DAY:
- return "day";
- case V1TimeGrain.TIME_GRAIN_WEEK:
- return "week";
- case V1TimeGrain.TIME_GRAIN_MONTH:
- return "month";
- case V1TimeGrain.TIME_GRAIN_YEAR:
- return "year";
- default:
- return timeGrain;
- }
-};
-
-// This is the wrong way to deal with this. We should be (1) calculating the time range first
-// then (2) getting the exact duration.
-const getLastXTimeRangeDurationMs = (name: TimeRangeName_DEPRECATE): number => {
- switch (name) {
- case TimeRangeName_DEPRECATE.LAST_SIX_HOURS:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_HOUR.duration) * 6;
- case TimeRangeName_DEPRECATE.LAST_24_HOURS:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration);
- case TimeRangeName_DEPRECATE.LAST_7_DAYS:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration) * 7;
- case TimeRangeName_DEPRECATE.LAST_4_WEEKS:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration) * 28;
-
- default:
- throw new Error(`Unknown last X time range name: ${name}`);
- }
-};
+export function getTimeDimensionOptions(
+ dimensions: MetricsViewSpecDimension[],
+ restrictedDimensions: string[] | undefined,
+) {
+ const timeDimensions = dimensions.filter(
+ (d) =>
+ d.type === MetricsViewSpecDimensionType.DIMENSION_TYPE_TIME &&
+ (!restrictedDimensions || restrictedDimensions.includes(d.name!)),
+ );
-// map from time grain to duration in ms.
-const getTimeGrainDurationMs = (timeGrain: V1TimeGrain): number => {
- switch (timeGrain) {
- case V1TimeGrain.TIME_GRAIN_MINUTE:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_MINUTE.duration);
- case V1TimeGrain.TIME_GRAIN_HOUR:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_HOUR.duration);
- case V1TimeGrain.TIME_GRAIN_DAY:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration);
- case V1TimeGrain.TIME_GRAIN_WEEK:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration) * 7;
- case V1TimeGrain.TIME_GRAIN_MONTH:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_DAY.duration) * 30;
- case V1TimeGrain.TIME_GRAIN_YEAR:
- return durationToMillis(TIME_GRAIN.TIME_GRAIN_YEAR.duration);
- default:
- throw new Error(`Unknown time grain: ${timeGrain}`);
+ if (restrictedDimensions) {
+ timeDimensions.sort(
+ (a, b) =>
+ restrictedDimensions.indexOf(a.name!) -
+ restrictedDimensions.indexOf(b.name!),
+ );
}
-};
-// might not need it
-export function makeRelativeTimeRange(
- timeRangeName: TimeRangeName_DEPRECATE,
- allTimeRange: TimeRange,
-): TimeRange {
- if (timeRangeName === TimeRangeName_DEPRECATE.ALL_TIME) return allTimeRange;
- const startTime = new Date(
- allTimeRange.end.getTime() - getLastXTimeRangeDurationMs(timeRangeName),
- );
- return {
- name: timeRangeName,
- start: startTime,
- end: allTimeRange.end,
- };
+ return timeDimensions.map((timeDim) => {
+ return {
+ value: timeDim.name!,
+ label: timeDim.displayName || timeDim.name!,
+ description: timeDim.description,
+ };
+ });
}
diff --git a/web-common/src/features/dashboards/workspace/Dashboard.svelte b/web-common/src/features/dashboards/workspace/Dashboard.svelte
index 2e78e25f02a6..9a5347082b72 100644
--- a/web-common/src/features/dashboards/workspace/Dashboard.svelte
+++ b/web-common/src/features/dashboards/workspace/Dashboard.svelte
@@ -133,8 +133,6 @@
}
: undefined;
- $: timeRanges = exploreSpec?.timeRanges ?? [];
-
$: visibleMeasureNames = $visibleMeasures.map(({ name }) => name ?? "");
// For non-embedded dashboards, theme can come from URL params.
@@ -173,7 +171,7 @@
{:else}
{#key exploreName}
-
+
diff --git a/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts
new file mode 100644
index 000000000000..5d74219d2d47
--- /dev/null
+++ b/web-common/src/features/metrics-views/providers/MetricsViewsProvider.svelte.ts
@@ -0,0 +1,279 @@
+import {
+ createQueryServiceMetricsViewTimeRange,
+ createRuntimeServiceListResources,
+ type MetricsViewSpecDimension,
+ type MetricsViewSpecMeasure,
+ type V1MetricsViewSpec,
+ type V1Resource,
+ V1TimeGrain,
+ type V1TimeRangeSummary,
+} from "@rilldata/web-common/runtime-client";
+import type { RuntimeClient } from "@rilldata/web-common/runtime-client/v2";
+import { isSimpleMeasure } from "@rilldata/web-common/features/dashboards/state-managers/selectors/measures.ts";
+import { Duration } from "luxon";
+import { queryClient } from "@rilldata/web-common/lib/svelte-query/globalQueryClient.ts";
+import { ResourceKind } from "@rilldata/web-common/features/entity-management/resource-selectors.ts";
+import { arrayUnorderedEquals } from "@rilldata/web-common/lib/arrayUtils.ts";
+import { V1TimeGrainToOrder } from "@rilldata/web-common/lib/time/new-grains.ts";
+
+export type MetricsViewName = string;
+export type DimensionName = string;
+export type MeasureName = string;
+
+/**
+ * Reactive view over a set of metrics views.
+ *
+ * Specs for every metrics view come from a single ListResources subscription.
+ * Time range summaries are fetched per metrics view, and only for the ones that have a time dimension,
+ * so the summaries arrive after the specs rather than alongside them.
+ *
+ * Measures and dimensions are exposed two ways:
+ * as deduped flat lists for pickers, and as name -> metrics view -> spec maps for callers that need to
+ * know which metrics views a given measure or dimension belongs to.
+ */
+export class MetricsViewsProvider {
+ /** Valid spec per metrics view name. Absent while the resource is loading or invalid. */
+ public specs = $state>({});
+ /** Time range summary per metrics view name. Absent for metrics views without a time dimension. */
+ public timeRangeSummaries = $state<
+ Record
+ >({});
+ /** Max queryable time range in milliseconds per metrics view name. Zero when unrestricted. */
+ public maxQueryTimeRangeMillis = $state>({});
+
+ /** Dimension spec per metrics view, keyed by dimension name (or column when unnamed). */
+ public dimensionSpecs = $state<
+ Record>
+ >({});
+ /**
+ * Measure spec per metrics view, keyed by measure name.
+ * The same measure name can be defined by more than one metrics view.
+ */
+ public measureSpecs = $state<
+ Record>
+ >({});
+
+ /** Deduped by name across metrics views; the first metrics view to define a name wins. */
+ public measures = $state([]);
+ public simpleMeasures = $state([]);
+ public dimensions = $state([]);
+
+ /** Union of the individual summaries: earliest min, latest max, latest watermark. */
+ public timeRangeSummary: V1TimeRangeSummary | undefined;
+ /** Smallest restriction across the metrics views, since it has to hold for all of them. */
+ public maxQueryTimeRange: Duration | undefined;
+ /** Smallest time grain across the metrics views, since it has to hold for all of them. */
+ public smallestTimeGrain: V1TimeGrain | undefined;
+ public smallestGrainOrder: number;
+ /** True once every metrics view has a spec and every time series metrics view has a summary. */
+ public ready: boolean;
+ public metricsViewNames = $state([]);
+
+ public cleanup: () => void;
+
+ private resources: V1Resource[] = [];
+ private readonly timeRangeUnsubs = new Map void>();
+
+ public constructor(
+ public readonly runtimeClient: RuntimeClient,
+ initMetricsViewNames: string[],
+ ) {
+ this.metricsViewNames = initMetricsViewNames.filter(Boolean);
+
+ const allResourcesQuery = createRuntimeServiceListResources(
+ runtimeClient,
+ {},
+ undefined,
+ queryClient,
+ );
+ const allResourcesUnsub = allResourcesQuery.subscribe(
+ (allResourcesResp) => {
+ this.resources = allResourcesResp.data?.resources ?? [];
+ this.processResources();
+ },
+ );
+
+ this.timeRangeSummary = $derived.by(() => {
+ let min: string | undefined;
+ let max: string | undefined;
+ let watermark: string | undefined;
+ let minTime = Infinity;
+ let maxTime = -Infinity;
+ let watermarkTime = -Infinity;
+
+ for (const metricsViewName of this.metricsViewNames) {
+ const summary = this.timeRangeSummaries[metricsViewName];
+ if (!summary) continue;
+
+ // Date.parse returns NaN for missing or malformed timestamps,
+ // and every comparison against NaN is false, so those simply never win.
+ const minCandidate = Date.parse(summary.min ?? "");
+ if (minCandidate < minTime) {
+ minTime = minCandidate;
+ min = summary.min;
+ }
+
+ const maxCandidate = Date.parse(summary.max ?? "");
+ if (maxCandidate > maxTime) {
+ maxTime = maxCandidate;
+ max = summary.max;
+ }
+
+ const watermarkCandidate = Date.parse(summary.watermark ?? "");
+ if (watermarkCandidate > watermarkTime) {
+ watermarkTime = watermarkCandidate;
+ watermark = summary.watermark;
+ }
+ }
+
+ if (!min && !max && !watermark) return undefined;
+ return { min, max, watermark };
+ });
+
+ this.maxQueryTimeRange = $derived.by(() => {
+ let smallestMillis = Infinity;
+ for (const metricsViewName of this.metricsViewNames) {
+ const millis = this.maxQueryTimeRangeMillis[metricsViewName] ?? 0;
+ if (millis > 0 && millis < smallestMillis) smallestMillis = millis;
+ }
+ return smallestMillis === Infinity
+ ? undefined
+ : Duration.fromMillis(smallestMillis);
+ });
+
+ this.ready = $derived(
+ this.metricsViewNames.length > 0 &&
+ this.metricsViewNames.every((metricsViewName) => {
+ const spec = this.specs[metricsViewName];
+ if (!spec) return false;
+ return (
+ !spec.timeDimension || !!this.timeRangeSummaries[metricsViewName]
+ );
+ }),
+ );
+
+ this.cleanup = () => {
+ allResourcesUnsub();
+ this.timeRangeUnsubs.forEach((unsub) => unsub());
+ this.timeRangeUnsubs.clear();
+ };
+ }
+
+ public setMetricsViewNames(metricsViewNames: string[]) {
+ metricsViewNames = metricsViewNames.filter(Boolean);
+ if (arrayUnorderedEquals(this.metricsViewNames, metricsViewNames)) return;
+ this.metricsViewNames = metricsViewNames;
+ this.processResources();
+ }
+
+ private processResources() {
+ const specs: Record = {};
+
+ const measureSpecs: Record<
+ string,
+ Record
+ > = {};
+ const measures: MetricsViewSpecMeasure[] = [];
+ const simpleMeasures: MetricsViewSpecMeasure[] = [];
+
+ const dimensionSpecs: Record<
+ string,
+ Record
+ > = {};
+ const dimensions: MetricsViewSpecDimension[] = [];
+
+ let smallestTimeGrain: V1TimeGrain | undefined = undefined;
+ let smallestGrainOrder: number | undefined = Infinity;
+
+ for (const metricsViewName of this.metricsViewNames) {
+ const res = this.resources.find(
+ (resource) =>
+ resource.meta?.name?.name === metricsViewName &&
+ resource.meta?.name?.kind === ResourceKind.MetricsView,
+ );
+ const spec = res?.metricsView?.state?.validSpec;
+ if (!spec) continue;
+ specs[metricsViewName] = spec;
+
+ spec.measures?.forEach((measure) => {
+ if (!measure.name) return;
+
+ let specsForMeasure = measureSpecs[measure.name];
+ if (!specsForMeasure) {
+ specsForMeasure = measureSpecs[measure.name] = {};
+ measures.push(measure);
+ if (isSimpleMeasure(measure)) simpleMeasures.push(measure);
+ }
+ specsForMeasure[metricsViewName] = measure;
+ });
+
+ spec.dimensions?.forEach((dimension) => {
+ // Filter expressions identify an unnamed dimension by its column.
+ const dimensionName = dimension.name || dimension.column;
+ if (!dimensionName) return;
+
+ let specsForDimension = dimensionSpecs[dimensionName];
+ if (!specsForDimension) {
+ specsForDimension = dimensionSpecs[dimensionName] = {};
+ dimensions.push(dimension);
+ }
+ specsForDimension[metricsViewName] = dimension;
+ });
+
+ if (spec.smallestTimeGrain) {
+ const specGrainOrder = V1TimeGrainToOrder[spec.smallestTimeGrain];
+
+ if (!smallestTimeGrain) {
+ smallestTimeGrain = spec.smallestTimeGrain;
+ smallestGrainOrder = specGrainOrder;
+ } else if (specGrainOrder < smallestGrainOrder) {
+ smallestTimeGrain = spec.smallestTimeGrain;
+ smallestGrainOrder = specGrainOrder;
+ }
+ }
+
+ this.subscribeToTimeRange(metricsViewName, spec);
+ }
+
+ this.specs = specs;
+ this.measureSpecs = measureSpecs;
+ this.measures = measures;
+ this.simpleMeasures = simpleMeasures;
+ this.dimensionSpecs = dimensionSpecs;
+ this.dimensions = dimensions;
+ this.smallestTimeGrain = smallestTimeGrain;
+ this.smallestGrainOrder = smallestTimeGrain
+ ? smallestGrainOrder
+ : V1TimeGrainToOrder[V1TimeGrain.TIME_GRAIN_MINUTE];
+ }
+
+ /**
+ * Starts the time range query for a metrics view the first time its spec shows up.
+ * Metrics views without a time dimension have no summary to fetch.
+ */
+ private subscribeToTimeRange(
+ metricsViewName: string,
+ spec: V1MetricsViewSpec,
+ ) {
+ if (!spec.timeDimension || this.timeRangeUnsubs.has(metricsViewName)) {
+ return;
+ }
+
+ const timeRangeQuery = createQueryServiceMetricsViewTimeRange(
+ this.runtimeClient,
+ { metricsViewName },
+ undefined,
+ queryClient,
+ );
+ this.timeRangeUnsubs.set(
+ metricsViewName,
+ timeRangeQuery.subscribe((timeRangeResp) => {
+ const summary = timeRangeResp.data?.timeRangeSummary;
+ if (summary) this.timeRangeSummaries[metricsViewName] = summary;
+ this.maxQueryTimeRangeMillis[metricsViewName] = Number(
+ timeRangeResp.data?.maxQueryTimeRangeMillis ?? 0,
+ );
+ }),
+ );
+ }
+}
diff --git a/web-common/src/lib/store-utils/url-params-store-sync.svelte.ts b/web-common/src/lib/store-utils/url-params-store-sync.svelte.ts
new file mode 100644
index 000000000000..d6f08ee8ff95
--- /dev/null
+++ b/web-common/src/lib/store-utils/url-params-store-sync.svelte.ts
@@ -0,0 +1,97 @@
+import { copyParamsToTarget } from "@rilldata/web-common/lib/url-utils.ts";
+import { cleanUrlParams } from "@rilldata/web-common/features/dashboards/url-state/clean-url-params.ts";
+import { page } from "$app/state";
+import { untrack } from "svelte";
+
+interface UrlParamsStore {
+ curStateParams: URLSearchParams;
+ curSetParams: URLSearchParams;
+ setUrlParams(urlParams: URLSearchParams): void;
+}
+
+export function syncStoreWithSource(
+ store: UrlParamsStore,
+ sync: (newUrlParams: URLSearchParams) => Promise,
+ readyGetter: () => boolean,
+ defaultUrlParamsGetter: () => URLSearchParams | undefined,
+) {
+ let lock = false;
+ let prevUrlSearch = "";
+
+ $effect(() => {
+ // Read all dependencies first so the subscription survives the guard.
+ const currentUrl = page.url;
+ const defaultUrlParams = untrack(() =>
+ defaultUrlParamsGetter ? defaultUrlParamsGetter() : undefined,
+ );
+ const ready = readyGetter();
+
+ if (!ready || lock) return;
+ lock = true;
+
+ const newUrlParams = new URLSearchParams(currentUrl.searchParams);
+ if (defaultUrlParams) {
+ defaultUrlParams.forEach((value, key) => {
+ if (newUrlParams.has(key)) return;
+ newUrlParams.set(key, value);
+ });
+ }
+
+ if (newUrlParams.toString() === prevUrlSearch) {
+ lock = false;
+ return;
+ }
+ prevUrlSearch = newUrlParams.toString();
+
+ console.log("syncStoreWithSource:fromUrl", newUrlParams.toString());
+ untrack(() => store.setUrlParams(newUrlParams));
+
+ lock = false;
+ });
+
+ $effect(() => {
+ // Read all dependencies first so the subscription survives the guard.
+ const curStateParams = store.curStateParams;
+ const curSetParams = untrack(() => store.curSetParams);
+ const defaultUrlParams = untrack(() =>
+ defaultUrlParamsGetter ? defaultUrlParamsGetter() : undefined,
+ );
+ const ready = readyGetter();
+
+ if (!ready || lock || curStateParams.toString() === curSetParams.toString())
+ return;
+ lock = true;
+
+ const currentUrlParams = untrack(() => page.url.searchParams);
+ let newUrlParams = new URLSearchParams(currentUrlParams);
+ copyParamsToTarget(curStateParams, newUrlParams);
+ if (defaultUrlParams) {
+ newUrlParams = cleanUrlParams(newUrlParams, defaultUrlParams);
+ }
+
+ if (newUrlParams.toString() === currentUrlParams.toString()) {
+ lock = false;
+ return;
+ }
+
+ console.log(
+ "syncStoreWithSource:toUrl",
+ newUrlParams.toString(),
+ currentUrlParams.toString(),
+ );
+ try {
+ const syncPromise = sync(newUrlParams);
+ if (!syncPromise.then) {
+ lock = false;
+ return;
+ }
+
+ void syncPromise.then(
+ () => (lock = false),
+ () => (lock = false),
+ );
+ } catch {
+ lock = false;
+ }
+ });
+}
diff --git a/web-common/src/lib/time/ranges/index.ts b/web-common/src/lib/time/ranges/index.ts
index 3ad9517100eb..89c0df04162b 100644
--- a/web-common/src/lib/time/ranges/index.ts
+++ b/web-common/src/lib/time/ranges/index.ts
@@ -34,7 +34,12 @@ import {
type TimeRangeOption,
TimeRangePreset,
} from "../types";
-import { DateTime, type DateTimeUnit } from "luxon";
+import {
+ DateTime,
+ type DateTimeUnit,
+ type DurationLike,
+ Interval,
+} from "luxon";
import { V1TimeGrainToDateTimeUnit } from "../new-grains";
// Loop through all presets to check if they can be a part of subset of given start and end date
@@ -205,6 +210,36 @@ export function getAdjustedFetchTime(
}
}
+/**
+ * Return Interval such that the results include extra data points for extrapolating the chart on both ends.
+ * Variant of {@link getAdjustedFetchTime} that takes and returns luxon Interval
+ */
+export function getAdjustedInterval(
+ interval: Interval,
+ grain: V1TimeGrain | undefined,
+ zone: string,
+): Interval | undefined {
+ if (interval?.isValid || !interval.start || !interval.end || !grain) {
+ return undefined;
+ }
+
+ const luxonUnit = V1TimeGrainToDateTimeUnit[grain];
+ const duration: DurationLike = { [luxonUnit]: 1 };
+
+ // Should only fail if somehow the Luxon unit is invalid
+ try {
+ const start = interval.start
+ .setZone(zone)
+ .minus(duration)
+ .startOf(luxonUnit);
+ const end = interval.end.setZone(zone).plus(duration).startOf(luxonUnit);
+
+ return Interval.fromDateTimes(start, end);
+ } catch {
+ return undefined;
+ }
+}
+
/**
* Return start and end date to be used as extents of the
* time series charts
diff --git a/web-common/src/lib/url-utils.ts b/web-common/src/lib/url-utils.ts
index d2f9e205cc1f..b1a3dd2d4559 100644
--- a/web-common/src/lib/url-utils.ts
+++ b/web-common/src/lib/url-utils.ts
@@ -43,3 +43,12 @@ export function unorderedParamsAreEqual(
}
return true;
}
+
+export function copySubsetParams(src: URLSearchParams, params: Set) {
+ const newParams = new URLSearchParams();
+ for (const param of params) {
+ if (!src.has(param)) continue;
+ newParams.set(param, src.get(param)!);
+ }
+ return newParams;
+}