diff --git a/frontend/src/lib/api/api.ts b/frontend/src/lib/api/api.ts index ee8dff6af3..b7a585b22a 100644 --- a/frontend/src/lib/api/api.ts +++ b/frontend/src/lib/api/api.ts @@ -1,8 +1,8 @@ import type { FeatureResponse, + JoinsResponse, JoinTimeSeriesResponse, - ModelsResponse, - TimeSeriesResponse + ModelsResponse } from '$lib/types/Model/Model'; import { error } from '@sveltejs/kit'; import { browser } from '$app/environment'; @@ -34,20 +34,12 @@ export async function getModels(): Promise { return get('models'); } -export async function getModelTimeseries( - name: string, - startTs: number, - endTs: number, - offset: string = '10h', - algorithm: string = 'psi' -): Promise { +export async function getJoins(offset: number = 0, limit: number = 10): Promise { const params = new URLSearchParams({ - startTs: startTs.toString(), - endTs: endTs.toString(), - offset, - algorithm + offset: offset.toString(), + limit: limit.toString() }); - return get(`model/${name}/timeseries?${params.toString()}`); + return get(`joins?${params.toString()}`); } export async function search(term: string, limit: number = 20): Promise { @@ -58,15 +50,23 @@ export async function search(term: string, limit: number = 20): Promise { +export async function getJoinTimeseries({ + joinId, + startTs, + endTs, + metricType = 'drift', + metrics = 'null', + offset = '10h', + algorithm = 'psi' +}: { + joinId: string; + startTs: number; + endTs: number; + metricType?: string; + metrics?: string; + offset?: string; + algorithm?: string; +}): Promise { const params = new URLSearchParams({ startTs: startTs.toString(), endTs: endTs.toString(), @@ -79,16 +79,27 @@ export async function getJoinTimeseries( return get(`join/${joinId}/timeseries?${params.toString()}`); } -export async function getFeatureTimeseries( - featureName: string, - startTs: number, - endTs: number, - metricType: string = 'drift', - metrics: string = 'null', - offset: string = '10h', - algorithm: string = 'psi', - granularity: string = 'percentile' -): Promise { +export async function getFeatureTimeseries({ + joinId, + featureName, + startTs, + endTs, + metricType = 'drift', + metrics = 'null', + offset = '10h', + algorithm = 'psi', + granularity = 'aggregates' +}: { + joinId: string; + featureName: string; + startTs: number; + endTs: number; + metricType?: string; + metrics?: string; + offset?: string; + algorithm?: string; + granularity?: string; +}): Promise { const params = new URLSearchParams({ startTs: startTs.toString(), endTs: endTs.toString(), @@ -98,5 +109,5 @@ export async function getFeatureTimeseries( algorithm, granularity }); - return get(`feature/${featureName}/timeseries?${params.toString()}`); + return get(`join/${joinId}/feature/${featureName}/timeseries?${params.toString()}`); } diff --git a/frontend/src/lib/components/ActionButtons/ActionButtons.svelte b/frontend/src/lib/components/ActionButtons/ActionButtons.svelte index bee9dcd120..25ed1a4883 100644 --- a/frontend/src/lib/components/ActionButtons/ActionButtons.svelte +++ b/frontend/src/lib/components/ActionButtons/ActionButtons.svelte @@ -2,11 +2,38 @@ import { Button } from '$lib/components/ui/button'; import { cn } from '$lib/utils'; import { Icon, Plus, ArrowsUpDown, Square3Stack3d, XMark } from 'svelte-hero-icons'; + import { goto } from '$app/navigation'; + import { page } from '$app/stores'; + import { + getSortDirection, + updateContextSort, + type SortDirection, + type SortContext + } from '$lib/util/sort'; - let { showCluster = false, class: className }: { showCluster?: boolean; class?: string } = - $props(); + let { + showCluster = false, + class: className, + showSort = false, + context = 'drift' + }: { + showCluster?: boolean; + class?: string; + showSort?: boolean; + context?: SortContext; + } = $props(); let activeCluster = showCluster ? 'GroupBys' : null; + + let currentSort: SortDirection = $derived.by(() => + getSortDirection($page.url.searchParams, context) + ); + + function handleSort() { + const newSort: SortDirection = currentSort === 'asc' ? 'desc' : 'asc'; + const url = updateContextSort($page.url, context, newSort); + goto(url, { replaceState: true }); + }
@@ -28,16 +55,18 @@
- + {/if} + - {#if showCluster} - diff --git a/frontend/src/lib/components/ChartControls/ChartControls.svelte b/frontend/src/lib/components/ChartControls/ChartControls.svelte new file mode 100644 index 0000000000..803e8310c3 --- /dev/null +++ b/frontend/src/lib/components/ChartControls/ChartControls.svelte @@ -0,0 +1,58 @@ + + +
+ {#if isUsingFallbackDates} +
+ + + No data for that date range. Showing data between {formatDate(dateRange.startTimestamp)} and + {formatDate(dateRange.endTimestamp)} + +
+ {/if} + +
+ {#if isZoomed} + + {/if} + + {#if context === 'drift'} + + {/if} +
+ + {#if showActionButtons} +
+ +
+ {/if} +
diff --git a/frontend/src/lib/components/CollapsibleSection/CollapsibleSection.svelte b/frontend/src/lib/components/CollapsibleSection/CollapsibleSection.svelte index 9da3feab1e..9a07f7abac 100644 --- a/frontend/src/lib/components/CollapsibleSection/CollapsibleSection.svelte +++ b/frontend/src/lib/components/CollapsibleSection/CollapsibleSection.svelte @@ -47,7 +47,7 @@ size="16" class="transition-transform duration-200 {open ? '' : 'rotate-180'}" /> -

{title}

+

{title}

{#if headerContentLeft} diff --git a/frontend/src/lib/components/CustomEChartLegend/CustomEChartLegend.svelte b/frontend/src/lib/components/CustomEChartLegend/CustomEChartLegend.svelte index 9051ba98b4..fe501b789a 100644 --- a/frontend/src/lib/components/CustomEChartLegend/CustomEChartLegend.svelte +++ b/frontend/src/lib/components/CustomEChartLegend/CustomEChartLegend.svelte @@ -2,6 +2,7 @@ import { Button } from '$lib/components/ui/button'; import type { EChartsType } from 'echarts'; import { Icon, ChevronDown, ChevronUp } from 'svelte-hero-icons'; + import { getSeriesColor, handleChartHighlight } from '$lib/util/chart'; type LegendItem = { feature: string }; type Props = { @@ -26,6 +27,10 @@ groupSet.delete(seriesName); } else { groupSet.add(seriesName); + chart.dispatchAction({ + type: 'downplay', + seriesName + }); } hiddenSeries = { @@ -40,10 +45,6 @@ }); } - function getSeriesColor(index: number, colors: string[]): string { - return colors[index % colors.length] || '#000000'; - } - function checkOverflow() { if (!itemsContainer) return; const hasVerticalOverflow = itemsContainer.scrollHeight > itemsContainer.clientHeight; @@ -65,25 +66,34 @@ return () => resizeObserver.disconnect(); }); + + function handleMouseEnter(seriesName: string) { + handleChartHighlight(chart, seriesName, 'highlight'); + } + + function handleMouseLeave(seriesName: string) { + handleChartHighlight(chart, seriesName, 'downplay'); + }
- {#each items as { feature }, index} - {@const colors = chart?.getOption()?.color || []} - {@const color = getSeriesColor(index, colors)} + {#each items as { feature } (feature)} {@const isHidden = hiddenSeries[groupName]?.has(feature)} + {@const color = getSeriesColor(chart, feature)} - -
diff --git a/frontend/src/lib/components/EChart/EChart.svelte b/frontend/src/lib/components/EChart/EChart.svelte index 116f83855c..3e70c8cf25 100644 --- a/frontend/src/lib/components/EChart/EChart.svelte +++ b/frontend/src/lib/components/EChart/EChart.svelte @@ -4,7 +4,7 @@ import type { ECElementEvent, EChartOption } from 'echarts'; import merge from 'lodash/merge'; import EChartTooltip from '$lib/components/EChartTooltip/EChartTooltip.svelte'; - import { getCssColorAsHex } from '$lib/util/colors'; + import CustomEChartLegend from '$lib/components/CustomEChartLegend/CustomEChartLegend.svelte'; let { option, @@ -15,6 +15,8 @@ height = '230px', enableCustomTooltip = false, enableTooltipClick = false, + showCustomLegend = false, + legendGroup = undefined, markPoint = undefined }: { option: EChartOption; @@ -25,6 +27,8 @@ height?: string; enableCustomTooltip?: boolean; enableTooltipClick?: boolean; + showCustomLegend?: boolean; + legendGroup?: { name: string; items: Array<{ feature: string }> }; markPoint?: ECElementEvent; } = $props(); const dispatch = createEventDispatcher(); @@ -87,17 +91,48 @@ let isCommandPressed = $state(false); let isMouseOverTooltip = $state(false); let hideTimeoutId: ReturnType; - let isBarChart = $state(false); + + const isBarChart = $derived.by(() => { + const series = mergedOption.series as EChartOption.Series[]; + return series?.some((s) => s.type === 'bar'); + }); function handleKeyDown(event: KeyboardEvent) { - if (event.metaKey || event.ctrlKey) { + if ((event.metaKey || event.ctrlKey) && event.type === 'keydown') { isCommandPressed = true; disableChartInteractions(); + + if (exactX !== null && chartInstance) { + const option = chartInstance.getOption(); + const series = option.series as EChartOption.Series[]; + const firstSeries = series[0]; + + if (Array.isArray(firstSeries.data)) { + // Find the index of the point with matching x-value + const dataIndex = firstSeries.data.findIndex( + (point) => (point as [number, number])[0] === exactX + ); + + if (dataIndex !== -1) { + // for some reason, we need to showTip somewhere else first + chartInstance.dispatchAction({ + type: 'showTip', + seriesIndex: 0, + dataIndex: -1 + }); + chartInstance.dispatchAction({ + type: 'showTip', + seriesIndex: 0, + dataIndex: dataIndex + }); + } + } + } } } function handleKeyUp(event: KeyboardEvent) { - if (!event.metaKey && !event.ctrlKey) { + if (event.key === 'Meta' || event.key === 'Control') { isCommandPressed = false; enableChartInteractions(); if (!isMouseOverTooltip) { @@ -108,13 +143,13 @@ function disableChartInteractions() { chartInstance?.setOption({ - silent: true + triggerOn: 'none' } as EChartOption); } function enableChartInteractions() { chartInstance?.setOption({ - silent: false + triggerOn: 'mousemove' } as EChartOption); } @@ -138,10 +173,6 @@ chartInstance = echarts.init(chartDiv, theme); chartInstance.setOption(mergedOption); - // Set chart type - const series = mergedOption.series as EChartOption.Series[]; - isBarChart = series?.[0]?.type === 'bar'; - chartInstance.on('click', (params: ECElementEvent) => { dispatch('click', { detail: params, @@ -204,6 +235,8 @@ zr.on('globalout', hideTooltip); } + let exactX = $state(null); + function showTooltip(params: { offsetX: number; offsetY: number }) { if (isCommandPressed) return; @@ -270,7 +303,7 @@ return Math.abs(currX - pointInGrid[0]) < Math.abs(prevX - pointInGrid[0]) ? curr : prev; }); - const exactX = (nearestPoint as [number, number])[0]; + exactX = (nearestPoint as [number, number])[0]; // Get values for all series at this exact x-coordinate const seriesData = series @@ -336,7 +369,7 @@ silent: true, symbol: [false, 'circle'], lineStyle: { - color: getCssColorAsHex('--neutral-700'), + color: '#ffffff', type: [8, 8], width: 1 }, @@ -401,6 +434,7 @@ xAxisCategories={isBarChart && chartInstance ? ((chartInstance.getOption()?.xAxis as EChartOption.XAxis[])?.[0]?.data as string[]) : undefined} + chart={chartInstance} on:click={(event) => dispatch('click', { detail: event.detail, @@ -410,3 +444,10 @@
{/if}
+{#if showCustomLegend && legendGroup && chartInstance} + +{/if} diff --git a/frontend/src/lib/components/EChartTooltip/EChartTooltip.svelte b/frontend/src/lib/components/EChartTooltip/EChartTooltip.svelte index 47ed4d4802..b6efc5940f 100644 --- a/frontend/src/lib/components/EChartTooltip/EChartTooltip.svelte +++ b/frontend/src/lib/components/EChartTooltip/EChartTooltip.svelte @@ -7,13 +7,16 @@ import Button from '$lib/components/ui/button/button.svelte'; import { Separator } from '$lib/components/ui/separator/'; import Badge from '$lib/components/ui/badge/badge.svelte'; + import { handleChartHighlight } from '$lib/util/chart'; + import type { EChartsType } from 'echarts'; let { visible, xValue, series, clickable = false, - xAxisCategories = undefined + xAxisCategories = undefined, + chart }: { visible: boolean; xValue: number | null; @@ -24,12 +27,14 @@ }>; clickable?: boolean; xAxisCategories?: string[]; + chart: EChartsType | null; } = $props(); const tooltipHeight = '300px'; const dispatch = createEventDispatcher(); function handleSeriesClick(item: (typeof series)[number]) { + if (!clickable) return; dispatch('click', { componentType: 'series', data: [xValue, item.value], @@ -46,48 +51,56 @@ return formatDate(xValue); } + + function handleMouseEnter(seriesName: string) { + handleChartHighlight(chart, seriesName, 'highlight'); + } + + function handleMouseLeave(seriesName: string) { + handleChartHighlight(chart, seriesName, 'downplay'); + } -
-
-
- {#if xValue !== null && visible} -
-
- {getTooltipTitle(xValue, xAxisCategories)} -
- -
- {#each series as item} - - {/each} -
-
- -
- {isMacOS() ? '⌘' : 'Ctrl'} to lock tooltip +
+ {formatValue(item.value)} + + {/each}
+ + +
+ {isMacOS() ? '⌘' : 'Ctrl'} to lock tooltip
- {/if} -
+
+ {/if}
diff --git a/frontend/src/lib/components/MetricTypeToggle/MetricTypeToggle.svelte b/frontend/src/lib/components/MetricTypeToggle/MetricTypeToggle.svelte new file mode 100644 index 0000000000..028dc6f762 --- /dev/null +++ b/frontend/src/lib/components/MetricTypeToggle/MetricTypeToggle.svelte @@ -0,0 +1,32 @@ + + +
+ {#each METRIC_TYPES as metricType} + + {/each} +
diff --git a/frontend/src/lib/components/PercentileChart/PercentileChart.svelte b/frontend/src/lib/components/PercentileChart/PercentileChart.svelte new file mode 100644 index 0000000000..ecae9d4623 --- /dev/null +++ b/frontend/src/lib/components/PercentileChart/PercentileChart.svelte @@ -0,0 +1,58 @@ + + + diff --git a/frontend/src/lib/components/ui/alert/alert-description.svelte b/frontend/src/lib/components/ui/alert/alert-description.svelte new file mode 100644 index 0000000000..06d344cc33 --- /dev/null +++ b/frontend/src/lib/components/ui/alert/alert-description.svelte @@ -0,0 +1,13 @@ + + +
+ +
diff --git a/frontend/src/lib/components/ui/alert/alert-title.svelte b/frontend/src/lib/components/ui/alert/alert-title.svelte new file mode 100644 index 0000000000..c63089bde4 --- /dev/null +++ b/frontend/src/lib/components/ui/alert/alert-title.svelte @@ -0,0 +1,21 @@ + + + + + diff --git a/frontend/src/lib/components/ui/alert/alert.svelte b/frontend/src/lib/components/ui/alert/alert.svelte new file mode 100644 index 0000000000..0bf6eec74d --- /dev/null +++ b/frontend/src/lib/components/ui/alert/alert.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/lib/components/ui/alert/index.ts b/frontend/src/lib/components/ui/alert/index.ts new file mode 100644 index 0000000000..c26e3ebef3 --- /dev/null +++ b/frontend/src/lib/components/ui/alert/index.ts @@ -0,0 +1,33 @@ +import { type VariantProps, tv } from 'tailwind-variants'; + +import Root from './alert.svelte'; +import Description from './alert-description.svelte'; +import Title from './alert-title.svelte'; + +export const alertVariants = tv({ + base: '[&>svg]:text-foreground relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg~*]:pl-7', + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + warning: 'border-warning-800 text-warning-800 [&>svg]:text-warning-800' + } + }, + defaultVariants: { + variant: 'default' + } +}); + +export type Variant = VariantProps['variant']; +export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + +export { + Root, + Description, + Title, + // + Root as Alert, + Description as AlertDescription, + Title as AlertTitle +}; diff --git a/frontend/src/lib/components/ui/button/button.svelte b/frontend/src/lib/components/ui/button/button.svelte index a785e95c87..cedf513ca4 100644 --- a/frontend/src/lib/components/ui/button/button.svelte +++ b/frontend/src/lib/components/ui/button/button.svelte @@ -21,6 +21,8 @@ {...$$restProps} on:click on:keydown + on:mouseenter + on:mouseleave > diff --git a/frontend/src/lib/types/MetricType/MetricType.ts b/frontend/src/lib/types/MetricType/MetricType.ts new file mode 100644 index 0000000000..633de1fdee --- /dev/null +++ b/frontend/src/lib/types/MetricType/MetricType.ts @@ -0,0 +1,21 @@ +export const METRIC_TYPES = ['jsd', 'hellinger', 'psi'] as const; +export type MetricType = (typeof METRIC_TYPES)[number]; + +export const DEFAULT_METRIC_TYPE: MetricType = 'psi'; + +export const METRIC_LABELS: Record = { + jsd: 'JSD', + hellinger: 'Hellinger', + psi: 'PSI' +}; + +export const METRIC_SCALES: Record = { + jsd: { min: 0, max: 1 }, + hellinger: { min: 0, max: 1 }, + psi: { min: 0, max: 25 } +}; + +export function getMetricTypeFromParams(searchParams: URLSearchParams): MetricType { + const metric = searchParams.get('metric'); + return METRIC_TYPES.includes(metric as MetricType) ? (metric as MetricType) : DEFAULT_METRIC_TYPE; +} diff --git a/frontend/src/lib/types/Model/Model.test.ts b/frontend/src/lib/types/Model/Model.test.ts index 948a9bad94..66aad9fdf3 100644 --- a/frontend/src/lib/types/Model/Model.test.ts +++ b/frontend/src/lib/types/Model/Model.test.ts @@ -1,12 +1,6 @@ import { describe, it, expect } from 'vitest'; import * as api from '$lib/api/api'; -import type { - ModelsResponse, - TimeSeriesResponse, - Model, - JoinTimeSeriesResponse, - FeatureResponse -} from '$lib/types/Model/Model'; +import type { ModelsResponse, Model, JoinTimeSeriesResponse } from '$lib/types/Model/Model'; describe('Model types', () => { it('should match ModelsResponse type', async () => { @@ -45,45 +39,6 @@ describe('Model types', () => { } }); - it('should match TimeSeriesResponse type', async () => { - const result = (await api.getModels()) as ModelsResponse; - expect(result.items.length).toBeGreaterThan(0); - - const modelName = result.items[0].name; - const timeseriesResult = (await api.getModelTimeseries( - modelName, - 1725926400000, - 1726106400000 - )) as TimeSeriesResponse; - - const expectedKeys = ['id', 'items']; - expect(Object.keys(timeseriesResult)).toEqual(expect.arrayContaining(expectedKeys)); - - // Log a warning if there are additional fields - const additionalKeys = Object.keys(timeseriesResult).filter( - (key) => !expectedKeys.includes(key) - ); - if (additionalKeys.length > 0) { - console.warn(`Additional fields found in TimeSeriesResponse: ${additionalKeys.join(', ')}`); - } - - expect(Array.isArray(timeseriesResult.items)).toBe(true); - - if (timeseriesResult.items.length > 0) { - const item = timeseriesResult.items[0]; - const expectedItemKeys = ['value', 'ts', 'label', 'nullValue']; - expect(Object.keys(item)).toEqual(expect.arrayContaining(expectedItemKeys)); - - // Log a warning if there are additional fields - const additionalItemKeys = Object.keys(item).filter((key) => !expectedItemKeys.includes(key)); - if (additionalItemKeys.length > 0) { - console.warn( - `Additional fields found in TimeSeriesResponse item: ${additionalItemKeys.join(', ')}` - ); - } - } - }); - it('should match ModelsResponse type for search results', async () => { const searchTerm = 'risk.transaction_model.v1'; const limit = 5; @@ -132,12 +87,12 @@ describe('Model types', () => { const result = (await api.getModels()) as ModelsResponse; expect(result.items.length).toBeGreaterThan(0); - const modelName = result.items[0].name; - const joinResult = (await api.getJoinTimeseries( - modelName, - 1725926400000, - 1726106400000 - )) as JoinTimeSeriesResponse; + const modelName = 'risk.user_transactions.txn_join'; + const joinResult = (await api.getJoinTimeseries({ + joinId: modelName, + startTs: 1673308800000, + endTs: 1674172800000 + })) as JoinTimeSeriesResponse; const expectedKeys = ['name', 'items']; expect(Object.keys(joinResult)).toEqual(expect.arrayContaining(expectedKeys)); @@ -202,12 +157,12 @@ describe('Model types', () => { }); it('should match FeatureResponse type', async () => { - const featureName = 'test_feature'; - const featureResult = (await api.getFeatureTimeseries( - featureName, - 1725926400000, - 1726106400000 - )) as FeatureResponse; + const featureResult = await api.getFeatureTimeseries({ + joinId: 'risk.user_transactions.txn_join', + featureName: 'dim_user_account_type', + startTs: 1673308800000, + endTs: 1674172800000 + }); const expectedKeys = ['feature', 'points']; expect(Object.keys(featureResult)).toEqual(expect.arrayContaining(expectedKeys)); @@ -220,7 +175,7 @@ describe('Model types', () => { expect(Array.isArray(featureResult.points)).toBe(true); - if (featureResult.points.length > 0) { + if (featureResult.points && featureResult.points?.length > 0) { const point = featureResult.points[0]; const expectedPointKeys = ['value', 'ts', 'label', 'nullValue']; expect(Object.keys(point)).toEqual(expect.arrayContaining(expectedPointKeys)); diff --git a/frontend/src/lib/types/Model/Model.ts b/frontend/src/lib/types/Model/Model.ts index 16b00bf963..b45377a094 100644 --- a/frontend/src/lib/types/Model/Model.ts +++ b/frontend/src/lib/types/Model/Model.ts @@ -47,7 +47,10 @@ export type JoinTimeSeriesResponse = { export type FeatureResponse = { feature: string; - points: TimeSeriesItem[]; + isNumeric?: boolean; + points?: TimeSeriesItem[]; + baseline?: TimeSeriesItem[]; + current?: TimeSeriesItem[]; }; export type RawComparedFeatureResponse = { @@ -62,3 +65,8 @@ export type NullComparedFeatureResponse = { oldValueCount: number; newValueCount: number; }; + +export type JoinsResponse = { + offset: number; + items: Join[]; +}; diff --git a/frontend/src/lib/util/chart-options.svelte.ts b/frontend/src/lib/util/chart-options.svelte.ts new file mode 100644 index 0000000000..7da101d2e1 --- /dev/null +++ b/frontend/src/lib/util/chart-options.svelte.ts @@ -0,0 +1,119 @@ +import type { EChartOption } from 'echarts'; +import merge from 'lodash/merge'; +import { getCssColorAsHex } from '$lib/util/colors'; + +let neutral300 = $state(''); +let neutral700 = $state(''); + +const colorInterval = setInterval(() => { + const color300 = getCssColorAsHex('--neutral-300'); + const color700 = getCssColorAsHex('--neutral-700'); + + if (color300 && color700) { + neutral300 = color300; + neutral700 = color700; + clearInterval(colorInterval); + } +}, 100); + +export function createChartOption( + customOption: Partial = {}, + customColors = false +): EChartOption { + const defaultOption: EChartOption = { + color: customColors + ? [ + '#E5174B', + '#E54D4A', + '#E17545', + '#E3994C', + '#DFAF4F', + '#87BE52', + '#53B167', + '#4DA67D', + '#4EA797', + '#4491CE', + '#4592CC', + '#4172D2', + '#5B5AD1', + '#785AD4', + '#9055D5', + '#BF50D3', + '#CB5587' + ] + : undefined, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'line', + lineStyle: { + color: neutral700, + type: 'solid' + } + }, + position: 'top', + confine: true + }, + xAxis: { + type: 'time', + axisLabel: { + formatter: { + month: '{MMM} {d}', + day: '{MMM} {d}' + } as unknown as string, + color: neutral700 + }, + splitLine: { + show: true, + lineStyle: { + color: neutral300 + } + }, + axisLine: { + lineStyle: { + color: neutral300 + } + } + }, + yAxis: { + type: 'value', + axisLabel: { + formatter: (value: number) => (value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)), + color: neutral700 + }, + splitLine: { + show: true, + lineStyle: { + color: neutral300 + } + }, + axisLine: { + lineStyle: { + color: neutral300 + } + } + }, + grid: { + top: 5, + right: 1, + bottom: 0, + left: 0, + containLabel: true + } + }; + + const baseSeriesStyle = { + showSymbol: false, + lineStyle: { + width: 1 + }, + symbolSize: 7 + }; + + if (customOption.series) { + const series = Array.isArray(customOption.series) ? customOption.series : [customOption.series]; + customOption.series = series.map((s) => merge({}, baseSeriesStyle, s)); + } + + return merge({}, defaultOption, customOption); +} diff --git a/frontend/src/lib/util/chart.ts b/frontend/src/lib/util/chart.ts new file mode 100644 index 0000000000..fc0e5e208a --- /dev/null +++ b/frontend/src/lib/util/chart.ts @@ -0,0 +1,37 @@ +import type { EChartsType } from 'echarts'; + +export function handleChartHighlight( + chart: EChartsType | null, + seriesName: string, + type: 'highlight' | 'downplay' +) { + if (!chart || !seriesName) return; + + // Get the series selected state from legend + const options = chart.getOption(); + const legendOpt = Array.isArray(options.legend) ? options.legend[0] : options.legend; + const isSelected = legendOpt?.selected?.[seriesName]; + + // Only highlight if the series is selected (visible) + if (isSelected !== false) { + chart.dispatchAction({ + type, + seriesName + }); + } +} + +export function getSeriesColor(chart: EChartsType | null, seriesName: string): string { + if (!chart) return '#000000'; + + const options = chart.getOption(); + if (!options?.series || !options?.color) return '#000000'; + + // Find the series index by name + const seriesIndex = options.series.findIndex((s) => s.name === seriesName); + if (seriesIndex === -1) return '#000000'; + + // Get color using the correct series index + const colors = options.color as string[]; + return colors[seriesIndex] || '#000000'; +} diff --git a/frontend/src/lib/util/colors.ts b/frontend/src/lib/util/colors.ts index a23faebd28..7e696078f1 100644 --- a/frontend/src/lib/util/colors.ts +++ b/frontend/src/lib/util/colors.ts @@ -1,7 +1,10 @@ import { hsl } from 'd3'; export function getCssColorAsHex(cssVariable: string) { + if (typeof window === 'undefined') return ''; + const hslValue = getComputedStyle(document.documentElement).getPropertyValue(cssVariable).trim(); + if (!hslValue) return ''; const [h, s, l] = hslValue.split(' ').map((val) => parseFloat(val.replace('%', ''))); return hsl(h, s / 100, l / 100).formatHex(); diff --git a/frontend/src/lib/util/sample-data.ts b/frontend/src/lib/util/sample-data.ts index aa7e21435c..75680a32b9 100644 --- a/frontend/src/lib/util/sample-data.ts +++ b/frontend/src/lib/util/sample-data.ts @@ -1,8 +1,4 @@ -import type { - FeatureResponse, - NullComparedFeatureResponse, - RawComparedFeatureResponse -} from '$lib/types/Model/Model'; +import type { FeatureResponse, RawComparedFeatureResponse } from '$lib/types/Model/Model'; export const percentileSampleData: FeatureResponse = { feature: 'my_feat', @@ -746,9 +742,71 @@ export const comparedFeatureCategoricalSampleData: RawComparedFeatureResponse = ] }; -export const nullCountSampleData: NullComparedFeatureResponse = { - oldNullCount: 10, - newNullCount: 20, - oldValueCount: 90, - newValueCount: 80 +export const nullCountSampleData: FeatureResponse = { + feature: 'feature_1', + current: [ + { ts: 1725926400000, value: 20, nullValue: 20 } // 20% null values in current + ], + baseline: [ + { ts: 1725926400000, value: 10, nullValue: 10 } // 10% null values in baseline + ] }; + +export function generatePercentileData( + numFeatures: number = 1, + numTimePoints: number = 24 +): FeatureResponse[] { + const percentileLabels = [ + 'p0', + 'p5', + 'p10', + 'p20', + 'p30', + 'p40', + 'p50', + 'p60', + 'p70', + 'p75', + 'p80', + 'p90', + 'p95', + 'p99', + 'p100' + ]; + + const currentTime = Date.now(); + const oneHour = 3600000; // 1 hour in milliseconds + const oneHourAgo = currentTime - oneHour; // Start from 1 hour ago + + const features: FeatureResponse[] = []; + + // Generate data for each feature + for (let f = 0; f < numFeatures; f++) { + const current = []; + + // Generate time series data going backwards from one hour ago + for (let i = 0; i < numTimePoints; i++) { + const timestamp = oneHourAgo - i * oneHour; + + // Generate points for each percentile at this timestamp + for (const label of percentileLabels) { + current.push({ + value: Math.random(), + ts: timestamp, + label, + nullValue: Math.floor(Math.random() * 31) + }); + } + } + + // Sort points by timestamp ascending (oldest to newest) + current.sort((a, b) => a.ts - b.ts); + + features.push({ + feature: `feature_${f + 1}`, + current + }); + } + + return features; +} diff --git a/frontend/src/lib/util/sort.ts b/frontend/src/lib/util/sort.ts new file mode 100644 index 0000000000..ddb3e9c362 --- /dev/null +++ b/frontend/src/lib/util/sort.ts @@ -0,0 +1,52 @@ +import type { FeatureResponse, JoinTimeSeriesResponse } from '$lib/types/Model/Model'; + +export type SortDirection = 'asc' | 'desc'; +export type SortContext = 'drift' | 'distributions'; + +export function getSortParamKey(context: SortContext): string { + return `${context}Sort`; +} + +export function getSortDirection( + searchParams: URLSearchParams, + context: SortContext +): SortDirection { + const param = searchParams.get(getSortParamKey(context)); + return param === 'desc' ? 'desc' : 'asc'; +} + +export function updateContextSort(url: URL, context: SortContext, direction: SortDirection): URL { + const newUrl = new URL(url); + newUrl.searchParams.set(getSortParamKey(context), direction); + return newUrl; +} + +export function sortDrift( + joinTimeseries: JoinTimeSeriesResponse, + direction: SortDirection +): JoinTimeSeriesResponse { + const sorted = { ...joinTimeseries }; + + // Sort main groups + sorted.items = [...joinTimeseries.items].sort((a, b) => { + const comparison = a.name.localeCompare(b.name); + return direction === 'asc' ? comparison : -comparison; + }); + + // Sort features within each group + sorted.items.forEach((group) => { + group.items.sort((a, b) => a.feature.localeCompare(b.feature)); + }); + + return sorted; +} + +export function sortDistributions( + distributions: FeatureResponse[], + direction: SortDirection +): FeatureResponse[] { + return [...distributions].sort((a, b) => { + const comparison = a.feature.localeCompare(b.feature); + return direction === 'asc' ? comparison : -comparison; + }); +} diff --git a/frontend/src/routes/joins/+page.server.ts b/frontend/src/routes/joins/+page.server.ts index 0f8a740294..ad59aa4dae 100644 --- a/frontend/src/routes/joins/+page.server.ts +++ b/frontend/src/routes/joins/+page.server.ts @@ -1,9 +1,11 @@ import type { PageServerLoad } from './$types'; -import type { ModelsResponse } from '$lib/types/Model/Model'; +import type { JoinsResponse } from '$lib/types/Model/Model'; import * as api from '$lib/api/api'; -export const load: PageServerLoad = async (): Promise<{ models: ModelsResponse }> => { +export const load: PageServerLoad = async (): Promise<{ joins: JoinsResponse }> => { + const offset = 0; + const limit = 100; return { - models: await api.getModels() + joins: await api.getJoins(offset, limit) }; }; diff --git a/frontend/src/routes/joins/+page.svelte b/frontend/src/routes/joins/+page.svelte index 274e458e17..759b365b48 100644 --- a/frontend/src/routes/joins/+page.svelte +++ b/frontend/src/routes/joins/+page.svelte @@ -1,5 +1,5 @@ @@ -27,32 +32,21 @@ Join - Model - Team - Type - Online - Production - {#each models as model} + {#each reorderedJoins as join} - - {model.join.name} + + {join.name} - - {model.name} - - {model.team} - {model.modelType} - - - - - - {/each} diff --git a/frontend/src/routes/joins/[slug]/+page.server.ts b/frontend/src/routes/joins/[slug]/+page.server.ts index 666edc558b..fb9c82ce8d 100644 --- a/frontend/src/routes/joins/[slug]/+page.server.ts +++ b/frontend/src/routes/joins/[slug]/+page.server.ts @@ -1,29 +1,101 @@ import type { PageServerLoad } from './$types'; import * as api from '$lib/api/api'; -import type { TimeSeriesResponse, JoinTimeSeriesResponse, Model } from '$lib/types/Model/Model'; +import type { JoinTimeSeriesResponse, Model } from '$lib/types/Model/Model'; import { parseDateRangeParams } from '$lib/util/date-ranges'; +import { getMetricTypeFromParams, type MetricType } from '$lib/types/MetricType/MetricType'; +import { getSortDirection, sortDrift, type SortDirection } from '$lib/util/sort'; + +const FALLBACK_START_TS = 1672531200000; // 2023-01-01 +const FALLBACK_END_TS = 1677628800000; // 2023-03-01 export const load: PageServerLoad = async ({ params, url }): Promise<{ - timeseries: TimeSeriesResponse; joinTimeseries: JoinTimeSeriesResponse; model?: Model; + metricType: MetricType; + dateRange: { + startTimestamp: number; + endTimestamp: number; + dateRangeValue: string; + isUsingFallback: boolean; + }; }> => { - const dateRange = parseDateRangeParams(url.searchParams); + const requestedDateRange = parseDateRangeParams(url.searchParams); + const joinName = params.slug; + const metricType = getMetricTypeFromParams(url.searchParams); + const sortDirection = getSortDirection(url.searchParams, 'drift'); + + // Try with requested date range first + try { + const { joinTimeseries, model } = await fetchInitialData( + joinName, + requestedDateRange.startTimestamp, + requestedDateRange.endTimestamp, + metricType, + sortDirection + ); + + return { + joinTimeseries, + model, + metricType, + dateRange: { + ...requestedDateRange, + isUsingFallback: false + } + }; + } catch (error) { + console.error('Error fetching data:', error); + // If the requested range fails, fall back to the known working range + const { joinTimeseries, model } = await fetchInitialData( + joinName, + FALLBACK_START_TS, + FALLBACK_END_TS, + metricType, + sortDirection + ); - const [timeseries, joinTimeseries, models] = await Promise.all([ - api.getModelTimeseries(params.slug, dateRange.startTimestamp, dateRange.endTimestamp), - api.getJoinTimeseries(params.slug, dateRange.startTimestamp, dateRange.endTimestamp), - await api.getModels() // todo eventually we will want to get a single model + return { + joinTimeseries, + model, + metricType, + dateRange: { + startTimestamp: FALLBACK_START_TS, + endTimestamp: FALLBACK_END_TS, + dateRangeValue: requestedDateRange.dateRangeValue, + isUsingFallback: true + } + }; + } +}; + +async function fetchInitialData( + joinName: string, + startTs: number, + endTs: number, + metricType: MetricType, + sortDirection: SortDirection +) { + const [joinTimeseries, models] = await Promise.all([ + api.getJoinTimeseries({ + joinId: joinName, + startTs, + endTs, + metricType: 'drift', + metrics: 'value', + offset: undefined, + algorithm: metricType + }), + api.getModels() ]); - const modelToReturn = models.items.find((m) => m.name === params.slug); + const sortedJoinTimeseries = sortDrift(joinTimeseries, sortDirection); + const modelToReturn = models.items.find((m) => m.join.name === joinName); return { - timeseries, - joinTimeseries, + joinTimeseries: sortedJoinTimeseries, model: modelToReturn }; -}; +} diff --git a/frontend/src/routes/joins/[slug]/+page.svelte b/frontend/src/routes/joins/[slug]/+page.svelte index 2744ce2e9d..7e95cad423 100644 --- a/frontend/src/routes/joins/[slug]/+page.svelte +++ b/frontend/src/routes/joins/[slug]/+page.svelte @@ -1,229 +1,70 @@ {#if shouldShowStickyHeader} @@ -543,17 +492,23 @@ class="sticky top-0 z-20 bg-neutral-200 border-b border-border -mx-8 py-2 px-8 border-l" transition:fade={{ duration: 150 }} > -
- {#if isZoomed} - - {/if} - - -
+
{/if} - + {#if model}
@@ -578,91 +533,47 @@
{/if} - - - - {#snippet headerContentLeft()} - - {/snippet} - {#snippet headerContentRight()} -
- {#if isZoomed} - - {/if} - -
-
- -
- {/snippet} - {#snippet collapsibleContent()} - - {/snippet} -
- - - - {#snippet headerContentLeft()} - - {/snippet} - {#snippet headerContentRight()} - -
-
- - {/snippet} - {#snippet collapsibleContent()} - - {/snippet} -
+
+ +
+
+ + +
{#snippet collapsibleContent()} - + - - - Features - - + - Monitoring + Drift + + + + Distributions - -
-

Features Content

-
-
- - - - {#each joinTimeseries.items as group} + + {#each joinTimeseries.items as group (group.name)} - {/snippet} {/each} + + {#if isLoadingDistributions} +
Loading distributions...
+ {:else if distributions.length === 0} +
No distribution data available
+ {:else} + {#each sortedDistributions as feature} + + {#snippet collapsibleContent()} + + {/snippet} + + {/each} + {/if} +
{/snippet}
@@ -700,18 +626,35 @@ - + highlightSeries(selectedSeries ?? '', dialogGroupChart, 'highlight')} + onmouseleave={() => highlightSeries(selectedSeries ?? '', dialogGroupChart, 'downplay')} + > {#if selectedSeries && dialogGroupChart}
{/if}
- {selectedSeries && selectedSeries + ' at'} + {selectedSeries ? `${selectedSeries} at ` : ''}{formatEventDate()}
- {formatEventDate()}
+
@@ -721,16 +664,6 @@ )} {#if selectedGroup} - {#snippet headerContentRight()} -
-
- {#if isZoomed} - - {/if} -
- -
- {/snippet} {#snippet collapsibleContent()} - {#if dialogGroupChart} - - {/if} {/snippet}
{/if} {/if} {#if selectedSeries} - - {#snippet collapsibleContent()} - - {/snippet} - - - - {#snippet headerContentRight()} - {#if isComparedFeatureZoomed} -
- -
- {/if} - {/snippet} - {#snippet collapsibleContent()} - - {/snippet} -
+ {#if percentileData} + + {#snippet collapsibleContent()} + + {/snippet} + + {/if} + {#if comparedFeatureData} + + {#snippet headerContentRight()} + {#if isComparedFeatureZoomed} +
+ +
+ {/if} + {/snippet} + {#snippet collapsibleContent()} + + {/snippet} +
+ {/if} {#snippet collapsibleContent()} selectSeries(event.seriesName)} autofocus={true} + onmouseenter={() => + highlightSeries(event.seriesName ?? '', dialogGroupChart, 'highlight')} + onmouseleave={() => + highlightSeries(event.seriesName ?? '', dialogGroupChart, 'downplay')} >
{event.seriesName ?? ''} diff --git a/spark/src/main/scala/ai/chronon/spark/scripts/ObservabilityDemoDataLoader.scala b/spark/src/main/scala/ai/chronon/spark/scripts/ObservabilityDemoDataLoader.scala index 258533a6ed..8bf4f52190 100644 --- a/spark/src/main/scala/ai/chronon/spark/scripts/ObservabilityDemoDataLoader.scala +++ b/spark/src/main/scala/ai/chronon/spark/scripts/ObservabilityDemoDataLoader.scala @@ -37,13 +37,13 @@ object ObservabilityDemoDataLoader { val endDs: ScallopOption[String] = opt[String]( name = "end-ds", - default = Some("2024-01-01"), + default = Some("2023-03-01"), descr = "End date in YYYY-MM-DD format" ) val rowCount: ScallopOption[Int] = opt[Int]( name = "row-count", - default = Some(1400000), + default = Some(700000), descr = "Number of rows to generate" )