feat: preferences framework generalised scaffolded

This commit is contained in:
sawhil 2025-05-13 01:24:54 +05:30
parent c7db85f44c
commit 5d52731c77
12 changed files with 538 additions and 1 deletions

View File

@ -240,6 +240,7 @@ function ExplorerOptions({
dataSource: sourcepage, dataSource: sourcepage,
aggregateOperator: StringOperators.NOOP, aggregateOperator: StringOperators.NOOP,
}); });
console.log('uncaught options in saved views', options);
const getUpdatedExtraData = ( const getUpdatedExtraData = (
extraData: string | undefined, extraData: string | undefined,
@ -338,6 +339,12 @@ function ExplorerOptions({
backwardCompatibleOptions = omit(options, 'version'); backwardCompatibleOptions = omit(options, 'version');
} }
console.log('uncaught backwardCompatibleOptions', {
backwardCompatibleOptions,
esc: extraData?.selectColumns,
osc: options.selectColumns,
});
if (extraData.selectColumns?.length) { if (extraData.selectColumns?.length) {
handleOptionsChange({ handleOptionsChange({
...backwardCompatibleOptions, ...backwardCompatibleOptions,
@ -419,6 +426,7 @@ function ExplorerOptions({
updatePreservedViewInLocalStorage(option); updatePreservedViewInLocalStorage(option);
console.log('uncaught options in saved views before call', options);
updateOrRestoreSelectColumns( updateOrRestoreSelectColumns(
option.key, option.key,
viewsData?.data?.data, viewsData?.data?.data,

View File

@ -23,6 +23,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlQueryData from 'hooks/useUrlQueryData'; import useUrlQueryData from 'hooks/useUrlQueryData';
import { isEqual, isNull } from 'lodash-es'; import { isEqual, isNull } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback'; import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
@ -35,6 +36,9 @@ function LogsExplorer(): JSX.Element {
const [selectedView, setSelectedView] = useState<SELECTED_VIEWS>( const [selectedView, setSelectedView] = useState<SELECTED_VIEWS>(
SELECTED_VIEWS.SEARCH, SELECTED_VIEWS.SEARCH,
); );
const { preferences, updateFormatting } = usePreferenceContext();
console.log('uncaught preferences', preferences);
const [showFilters, setShowFilters] = useState<boolean>(() => { const [showFilters, setShowFilters] = useState<boolean>(() => {
const localStorageValue = getLocalStorageKey( const localStorageValue = getLocalStorageKey(
LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS,
@ -222,6 +226,21 @@ function LogsExplorer(): JSX.Element {
</section> </section>
)} )}
<section className={cx('log-module-right-section')}> <section className={cx('log-module-right-section')}>
{/* dummy button to test the updateFormatting function */}
<div>
<button
type="button"
onClick={(): void => updateFormatting({ maxLines: 10 })}
>
<h1>Update formatting</h1>
</button>
</div>
{preferences && (
<div>
<h1>Preferences</h1>
<pre>{JSON.stringify(preferences, null, 2)}</pre>
</div>
)}
<Toolbar <Toolbar
showAutoRefresh={false} showAutoRefresh={false}
leftActions={ leftActions={

View File

@ -4,9 +4,14 @@ import { Compass, TowerControl, Workflow } from 'lucide-react';
import LogsExplorer from 'pages/LogsExplorer'; import LogsExplorer from 'pages/LogsExplorer';
import Pipelines from 'pages/Pipelines'; import Pipelines from 'pages/Pipelines';
import SaveView from 'pages/SaveView'; import SaveView from 'pages/SaveView';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
export const logsExplorer: TabRoutes = { export const logsExplorer: TabRoutes = {
Component: LogsExplorer, Component: (): JSX.Element => (
<PreferenceContextProvider>
<LogsExplorer />
</PreferenceContextProvider>
),
name: ( name: (
<div className="tab-item"> <div className="tab-item">
<Compass size={16} /> Explorer <Compass size={16} /> Explorer

View File

@ -0,0 +1,66 @@
/* eslint-disable no-empty */
import getLocalStorageKey from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FormattingOptions } from '../types';
// --- LOGS preferences loader config ---
const logsLoaders = {
local: async (): Promise<{
columns: BaseAutocompleteData[];
formatting: FormattingOptions;
}> => {
const local = getLocalStorageKey(LOCALSTORAGE.LOGS_LIST_OPTIONS);
if (local) {
try {
const parsed = JSON.parse(local);
return {
columns: parsed.selectColumns || [],
formatting: {
maxLines: parsed.maxLines ?? 2,
format: parsed.format ?? 'table',
fontSize: parsed.fontSize ?? 'small',
version: parsed.version ?? 1,
},
};
} catch {}
}
return { columns: [], formatting: undefined } as any;
},
url: async (): Promise<{
columns: BaseAutocompleteData[];
formatting: FormattingOptions;
}> => {
const urlParams = new URLSearchParams(window.location.search);
try {
const options = JSON.parse(urlParams.get('options') || '{}');
return {
columns: options.selectColumns || [],
formatting: {
maxLines: options.maxLines ?? 2,
format: options.format ?? 'table',
fontSize: options.fontSize ?? 'small',
version: options.version ?? 1,
},
};
} catch {}
return { columns: [], formatting: undefined } as any;
},
default: async (): Promise<{
columns: BaseAutocompleteData[];
formatting: FormattingOptions;
}> => ({
columns: defaultLogsSelectedColumns as BaseAutocompleteData[],
formatting: {
maxLines: 2,
format: 'table',
fontSize: 'small',
version: 1,
},
}),
priority: ['local', 'url', 'default'] as const,
};
export default logsLoaders;

View File

@ -0,0 +1,45 @@
import setLocalStorageKey from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FormattingOptions } from '../types';
// --- LOGS preferences updater config ---
const logsUpdater = {
updateColumns: (newColumns: BaseAutocompleteData[], mode: string): void => {
// Always update URL
const url = new URL(window.location.href);
const options = JSON.parse(url.searchParams.get('options') || '{}');
options.selectColumns = newColumns;
url.searchParams.set('options', JSON.stringify(options));
window.history.replaceState({}, '', url.toString());
if (mode === 'direct') {
// Also update local storage
const local = JSON.parse(
localStorage.getItem(LOCALSTORAGE.LOGS_LIST_OPTIONS) || '{}',
);
local.selectColumns = newColumns;
setLocalStorageKey(LOCALSTORAGE.LOGS_LIST_OPTIONS, JSON.stringify(local));
}
},
updateFormatting: (newFormatting: FormattingOptions, mode: string): void => {
// Always update URL
const url = new URL(window.location.href);
const options = JSON.parse(url.searchParams.get('options') || '{}');
Object.assign(options, newFormatting);
url.searchParams.set('options', JSON.stringify(options));
window.history.replaceState({}, '', url.toString());
if (mode === 'direct') {
// Also update local storage
const local = JSON.parse(
localStorage.getItem(LOCALSTORAGE.LOGS_LIST_OPTIONS) || '{}',
);
Object.assign(local, newFormatting);
setLocalStorageKey(LOCALSTORAGE.LOGS_LIST_OPTIONS, JSON.stringify(local));
}
},
};
export default logsUpdater;

View File

@ -0,0 +1,43 @@
/* eslint-disable no-empty */
import getLocalStorageKey from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
// --- TRACES preferences loader config ---
const tracesLoaders = {
local: async (): Promise<{
columns: BaseAutocompleteData[];
}> => {
const local = getLocalStorageKey(LOCALSTORAGE.TRACES_LIST_OPTIONS);
if (local) {
try {
const parsed = JSON.parse(local);
return {
columns: parsed.selectColumns || [],
};
} catch {}
}
return { columns: [] };
},
url: async (): Promise<{
columns: BaseAutocompleteData[];
}> => {
const urlParams = new URLSearchParams(window.location.search);
try {
const options = JSON.parse(urlParams.get('options') || '{}');
return {
columns: options.selectColumns || [],
};
} catch {}
return { columns: [] };
},
default: async (): Promise<{
columns: BaseAutocompleteData[];
}> => ({
columns: defaultTraceSelectedColumns as BaseAutocompleteData[],
}),
priority: ['local', 'url', 'default'] as const,
};
export default tracesLoaders;

View File

@ -0,0 +1,25 @@
import setLocalStorageKey from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
// --- TRACES preferences updater config ---
const tracesUpdater = {
updateColumns: (newColumns: BaseAutocompleteData[], mode: string): void => {
const url = new URL(window.location.href);
const options = JSON.parse(url.searchParams.get('options') || '{}');
options.selectColumns = newColumns;
url.searchParams.set('options', JSON.stringify(options));
window.history.replaceState({}, '', url.toString());
if (mode === 'direct') {
const local = JSON.parse(
localStorage.getItem(LOCALSTORAGE.TRACES_LIST_OPTIONS) || '{}',
);
local.selectColumns = newColumns;
setLocalStorageKey(LOCALSTORAGE.TRACES_LIST_OPTIONS, JSON.stringify(local));
}
},
updateFormatting: (): void => {}, // no-op for traces
};
export default tracesUpdater;

View File

@ -0,0 +1,78 @@
import { PreferenceContextValue } from 'providers/preferences/types';
import React, { createContext, useContext, useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { DataSource } from 'types/common/queryBuilder';
import { usePreferenceSync } from '../sync/usePreferenceSync';
// This will help in identifying the mode of the preference context
// savedView - when the preference is loaded from a saved view
// direct - when the preference is loaded from a direct query
export type PreferenceMode = 'savedView' | 'direct';
const PreferenceContext = createContext<PreferenceContextValue | undefined>(
undefined,
);
export function PreferenceContextProvider({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
const location = useLocation();
const params = new URLSearchParams(location.search);
const savedViewId = params.get('view');
let dataSource: DataSource = DataSource.LOGS;
if (location.pathname.includes('traces')) dataSource = DataSource.TRACES;
const {
preferences,
loading,
error,
updateColumns,
updateFormatting,
} = usePreferenceSync({
mode: savedViewId ? 'savedView' : 'direct',
savedViewId: savedViewId || undefined,
dataSource,
});
const value = useMemo<PreferenceContextValue>(
() => ({
preferences,
loading,
error,
mode: savedViewId ? 'savedView' : 'direct',
savedViewId: savedViewId || undefined,
dataSource,
updateColumns,
updateFormatting,
}),
[
savedViewId,
dataSource,
preferences,
loading,
error,
updateColumns,
updateFormatting,
],
);
return (
<PreferenceContext.Provider value={value}>
{children}
</PreferenceContext.Provider>
);
}
export function usePreferenceContext(): PreferenceContextValue {
const ctx = useContext(PreferenceContext);
if (!ctx)
throw new Error(
'usePreferenceContext must be used within PreferenceContextProvider',
);
return ctx;
}

View File

@ -0,0 +1,130 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable no-empty */
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
} from 'container/OptionsMenu/constants';
import { useGetAllViews } from 'hooks/saveViews/useGetAllViews';
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import logsLoaderConfig from '../configs/logsLoaderConfig';
import tracesLoaderConfig from '../configs/tracesLoaderConfig';
import { FormattingOptions, PreferenceMode, Preferences } from '../types';
// Generic preferences loader that works with any config
async function preferencesLoader<T>(config: {
priority: readonly string[];
[key: string]: any;
}): Promise<T> {
const findValidLoader = async (): Promise<T> => {
// Try each loader in priority order
const results = await Promise.all(
config.priority.map(async (source) => ({
source,
result: await config[source](),
})),
);
// Find the first result with columns
const validResult = results.find(({ result }) => result.columns.length);
if (validResult) {
return validResult.result;
}
// fallback to default
return config.default();
};
return findValidLoader();
}
// Use the generic loader with specific configs
async function logsPreferencesLoader(): Promise<{
columns: BaseAutocompleteData[];
formatting: FormattingOptions;
}> {
return preferencesLoader(logsLoaderConfig);
}
async function tracesPreferencesLoader(): Promise<{
columns: BaseAutocompleteData[];
}> {
return preferencesLoader(tracesLoaderConfig);
}
export function usePreferenceLoader({
mode,
savedViewId,
dataSource,
reSync,
}: {
mode: PreferenceMode;
savedViewId: string;
dataSource: DataSource;
reSync: number;
}): {
preferences: Preferences | null;
loading: boolean;
error: Error | null;
} {
const [preferences, setPreferences] = useState<Preferences | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const location = useLocation();
const { data: viewsData } = useGetAllViews(dataSource);
console.log('uncaught viewsData', viewsData);
useEffect((): void => {
async function loadPreferences(): Promise<void> {
setLoading(true);
setError(null);
try {
if (mode === 'savedView' && savedViewId) {
// we can also switch to the URL options params
// as we are essentially setting the options in the URL
// in ExplorerOptions.tsx#430 (updateOrRestoreSelectColumns)
const extraData = viewsData?.data?.data?.find(
(view) => view.id === savedViewId,
)?.extraData;
const parsedExtraData = JSON.parse(extraData || '{}');
let columns: BaseAutocompleteData[] = [];
let formatting: FormattingOptions | undefined;
if (dataSource === DataSource.LOGS) {
columns = parsedExtraData?.selectColumns || defaultLogsSelectedColumns;
formatting = {
maxLines: parsedExtraData?.maxLines ?? 2,
format: parsedExtraData?.format ?? 'table',
fontSize: parsedExtraData?.fontSize ?? 'small',
version: parsedExtraData?.version ?? 1,
};
} else if (dataSource === DataSource.TRACES) {
columns = parsedExtraData?.selectColumns || defaultTraceSelectedColumns;
}
setPreferences({ columns, formatting });
} else {
if (dataSource === DataSource.LOGS) {
const { columns, formatting } = await logsPreferencesLoader();
setPreferences({ columns, formatting });
}
if (dataSource === DataSource.TRACES) {
const { columns } = await tracesPreferencesLoader();
setPreferences({ columns });
}
}
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
}
loadPreferences();
}, [mode, savedViewId, dataSource, location, reSync, viewsData]);
return { preferences, loading, error };
}

View File

@ -0,0 +1,41 @@
import { useState } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import { usePreferenceLoader } from '../loader/usePreferenceLoader';
import { FormattingOptions, PreferenceMode, Preferences } from '../types';
import { usePreferenceUpdater } from '../updater/usePreferenceUpdater';
export function usePreferenceSync({
mode,
dataSource,
savedViewId,
}: {
mode: PreferenceMode;
dataSource: DataSource;
savedViewId: string | undefined;
}): {
preferences: Preferences | null;
loading: boolean;
error: Error | null;
updateColumns: (newColumns: BaseAutocompleteData[]) => void;
updateFormatting: (newFormatting: FormattingOptions) => void;
} {
// We are using a reSync state because we have URL updates as well as local storage updates
// and we want to make sure we are always using the latest preferences
const [reSync, setReSync] = useState(0);
const { preferences, loading, error } = usePreferenceLoader({
mode,
savedViewId: savedViewId || '',
dataSource,
reSync,
});
const { updateColumns, updateFormatting } = usePreferenceUpdater({
dataSource,
mode,
setReSync,
});
return { preferences, loading, error, updateColumns, updateFormatting };
}

View File

@ -0,0 +1,27 @@
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
export type PreferenceMode = 'savedView' | 'direct';
export interface PreferenceContextValue {
preferences: Preferences | null;
loading: boolean;
error: Error | null;
mode: PreferenceMode;
savedViewId?: string;
dataSource: DataSource;
updateColumns: (newColumns: BaseAutocompleteData[]) => void;
updateFormatting: (newFormatting: FormattingOptions) => void;
}
export interface FormattingOptions {
maxLines?: number;
format?: 'raw' | 'table';
fontSize?: 'small' | 'medium' | 'large';
version?: number;
}
export interface Preferences {
columns: BaseAutocompleteData[];
formatting?: FormattingOptions;
}

View File

@ -0,0 +1,50 @@
import { Dispatch, SetStateAction } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { DataSource } from 'types/common/queryBuilder';
import logsUpdater from '../configs/logsUpdaterConfig';
import tracesUpdater from '../configs/tracesUpdaterConfig';
import { FormattingOptions } from '../types';
const metricsUpdater = {
updateColumns: (): void => {}, // no-op for metrics
updateFormatting: (): void => {}, // no-op for metrics
};
const updaterConfig: Record<
DataSource,
{
updateColumns: (newColumns: BaseAutocompleteData[], mode: string) => void;
updateFormatting: (newFormatting: FormattingOptions, mode: string) => void;
}
> = {
[DataSource.LOGS]: logsUpdater,
[DataSource.TRACES]: tracesUpdater,
[DataSource.METRICS]: metricsUpdater,
};
export function usePreferenceUpdater({
dataSource,
mode,
setReSync,
}: {
dataSource: DataSource;
mode: string;
setReSync: Dispatch<SetStateAction<number>>;
}): {
updateColumns: (newColumns: BaseAutocompleteData[]) => void;
updateFormatting: (newFormatting: FormattingOptions) => void;
} {
const updater = updaterConfig[dataSource];
return {
updateColumns: (newColumns: BaseAutocompleteData[]): void => {
updater.updateColumns(newColumns, mode);
setReSync((prev: number) => prev + 1);
},
updateFormatting: (newFormatting: FormattingOptions): void => {
updater.updateFormatting(newFormatting, mode);
setReSync((prev: number) => prev + 1);
},
};
}