diff --git a/web-common/src/features/dashboards/filters/Filters.svelte b/web-common/src/features/dashboards/filters/Filters.svelte index a047aebf6125..2361e0c4e694 100644 --- a/web-common/src/features/dashboards/filters/Filters.svelte +++ b/web-common/src/features/dashboards/filters/Filters.svelte @@ -1,60 +1,26 @@
{#if hasTimeSeries} -
- - - - - - - - - {#if allTimeRange?.start && allTimeRange?.end} - - - {/if} - - {#if !$rillTime && allTimeRangeInterval?.end?.isValid} - - - - {m.dashboard_as_of()} - - - - - - - - {/if} -
+ {/if}
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; +}