Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Logs panel: Direction and wrap URL state #985

Merged
merged 8 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 41 additions & 14 deletions src/Components/ServiceScene/LogOptionsScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import { LogsListScene } from './LogsListScene';
import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'services/analytics';
import { LogsPanelHeaderActions } from '../Table/LogsHeaderActions';
import { GrafanaTheme2, LogsSortOrder } from '@grafana/data';
import { LogsPanelScene } from './LogsPanelScene';
import { locationService } from '@grafana/runtime';
import { narrowLogsSortOrder } from '../../services/narrowing';
import { logger } from '../../services/logger';

interface LogOptionsState extends SceneObjectState {
wrapLogMessage?: boolean;
visualizationType: LogsVisualizationType;
onChangeVisualizationType: (type: LogsVisualizationType) => void;
sortOrder?: LogsSortOrder;
}

/**
Expand All @@ -24,30 +26,32 @@ export class LogOptionsScene extends SceneObjectBase<LogOptionsState> {
constructor(state: LogOptionsState) {
super({
...state,
sortOrder: getLogsPanelSortOrder(),
wrapLogMessage: Boolean(getLogOption<boolean>('wrapLogMessage', false)),
});
}

handleWrapLinesChange = (type: boolean) => {
this.setState({ wrapLogMessage: type });
this.getLogsPanelScene().setState({ wrapLogMessage: type });
setLogOption('wrapLogMessage', type);
this.getParentScene().setLogsVizOption({ wrapLogMessage: type });
this.getParentScene().setLogsVizOption({ prettifyLogMessage: type });
this.getLogsListScene().setLogsVizOption({ wrapLogMessage: type });
this.getLogsListScene().setLogsVizOption({ prettifyLogMessage: type });
};

onChangeLogsSortOrder = (sortOrder: LogsSortOrder) => {
this.setState({ sortOrder: sortOrder });
this.getLogsPanelScene().setState({ sortOrder: sortOrder });
setLogOption('sortOrder', sortOrder);
this.getParentScene().setLogsVizOption({ sortOrder: sortOrder });
this.getLogsListScene().setLogsVizOption({ sortOrder: sortOrder });
};

getParentScene = () => {
getLogsListScene = () => {
return sceneGraph.getAncestor(this, LogsListScene);
};

getLogsPanelScene = () => {
return sceneGraph.getAncestor(this, LogsPanelScene);
};

clearDisplayedFields = () => {
const parentScene = this.getParentScene();
const parentScene = this.getLogsListScene();
parentScene.clearDisplayedFields();
reportAppInteraction(
USER_EVENTS_PAGES.service_details,
Expand All @@ -57,8 +61,9 @@ export class LogOptionsScene extends SceneObjectBase<LogOptionsState> {
}

function LogOptionsRenderer({ model }: SceneComponentProps<LogOptionsScene>) {
const { wrapLogMessage, onChangeVisualizationType, visualizationType, sortOrder } = model.useState();
const { displayedFields } = model.getParentScene().useState();
const { onChangeVisualizationType, visualizationType } = model.useState();
const { wrapLogMessage, sortOrder } = model.getLogsPanelScene().useState();
const { displayedFields } = model.getLogsListScene().useState();
const styles = useStyles2(getStyles);
const wrapLines = wrapLogMessage ?? false;

Expand Down Expand Up @@ -115,10 +120,32 @@ function LogOptionsRenderer({ model }: SceneComponentProps<LogOptionsScene>) {
);
}

export function getLogsPanelSortOrder() {
export function getLogsPanelSortOrderFromStore() {
return getLogOption<LogsSortOrder>('sortOrder', LogsSortOrder.Descending) as LogsSortOrder;
}

export function getLogsPanelSortOrderFromURL() {
// Since sort order is used to execute queries before the logs panel is instantiated, the scene url state will never influence the query
// Hacking this for now to manually check the URL search params to override local storage state if set
const location = locationService.getLocation();
const search = new URLSearchParams(location.search);
const sortOrder = search.get('sortOrder');

try {
if (typeof sortOrder === 'string') {
const decodedSortOrder = narrowLogsSortOrder(JSON.parse(sortOrder));
if (decodedSortOrder) {
return decodedSortOrder;
}
}
} catch (e) {
// URL Params can be manually changed and it will make JSON.parse() fail.
logger.error(e, { msg: 'LogOptionsScene(getLogsPanelSortOrderFromURL): unable to parse sortOrder' });
}

return false;
}

const getStyles = (theme: GrafanaTheme2) => ({
container: css({
display: 'flex',
Expand Down
98 changes: 85 additions & 13 deletions src/Components/ServiceScene/LogsPanelScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
sceneGraph,
SceneObjectBase,
SceneObjectState,
SceneObjectUrlSyncConfig,
SceneObjectUrlValues,
SceneQueryRunner,
VizPanel,
} from '@grafana/scenes';
Expand All @@ -20,28 +22,93 @@ import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from '..
import { getAdHocFiltersVariable, getValueFromFieldsFilter } from '../../services/variableGetters';
import { copyText, generateLogShortlink, resolveRowTimeRangeForSharing } from 'services/text';
import { CopyLinkButton } from './CopyLinkButton';
import { getLogsPanelSortOrder, LogOptionsScene } from './LogOptionsScene';
import { getLogsPanelSortOrderFromStore, LogOptionsScene } from './LogOptionsScene';
import { LogsVolumePanel, logsVolumePanelKey } from './LogsVolumePanel';
import { getPanelWrapperStyles, PanelMenu } from '../Panels/PanelMenu';
import { ServiceScene } from './ServiceScene';
import { Options } from '@grafana/schema/dist/esm/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen';
import { locationService } from '@grafana/runtime';
import { narrowLogsSortOrder } from '../../services/narrowing';
import { logger } from '../../services/logger';
import { LogsSortOrder } from '@grafana/schema';

interface LogsPanelSceneState extends SceneObjectState {
body?: VizPanel;
body?: VizPanel<Options>;
sortOrder?: LogsSortOrder;
wrapLogMessage?: boolean;
}

export class LogsPanelScene extends SceneObjectBase<LogsPanelSceneState> {
protected _urlSync = new SceneObjectUrlSyncConfig(this, {
keys: ['sortOrder', 'wrapLogMessage'],
});

constructor(state: Partial<LogsPanelSceneState>) {
super({
sortOrder: getLogsPanelSortOrderFromStore(),
wrapLogMessage: Boolean(getLogOption<boolean>('wrapLogMessage', false)),
...state,
});

this.addActivationHandler(this.onActivate.bind(this));
}

private setStateFromUrl() {
const searchParams = new URLSearchParams(locationService.getLocation().search);

this.updateFromUrl({
sortOrder: searchParams.get('sortOrder'),
wrapLogMessage: searchParams.get('wrapLogMessage'),
});
}

getUrlState() {
return {
sortOrder: JSON.stringify(this.state.sortOrder),
wrapLogMessage: JSON.stringify(this.state.wrapLogMessage),
};
}

updateFromUrl(values: SceneObjectUrlValues) {
const stateUpdate: Partial<LogsPanelSceneState> = {};
try {
if (typeof values.sortOrder === 'string') {
const decodedSortOrder = narrowLogsSortOrder(JSON.parse(values.sortOrder));
if (decodedSortOrder) {
stateUpdate.sortOrder = decodedSortOrder;
this.setLogsVizOption({ sortOrder: decodedSortOrder });
}
}

if (typeof values.wrapLogMessage === 'string') {
const decodedWrapLogMessage = JSON.parse(values.wrapLogMessage);
if (typeof decodedWrapLogMessage === 'boolean') {
stateUpdate.wrapLogMessage = decodedWrapLogMessage;
this.setLogsVizOption({ wrapLogMessage: decodedWrapLogMessage });
this.setLogsVizOption({ prettifyLogMessage: decodedWrapLogMessage });
}
}
} catch (e) {
// URL Params can be manually changed and it will make JSON.parse() fail.
logger.error(e, { msg: 'LogOptionsScene: updateFromUrl unexpected error' });
}

if (Object.keys(stateUpdate).length) {
this.setState({ ...stateUpdate });
}
}

Comment on lines +56 to +100
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to store in store if we update from URL? I navigated from a link, had wrapping: true, went to service selection, selected a new service, and wrapping: false.
Would be similar question for sort order.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would argue we only want to update local storage state when the user makes an action, e.g. if I'm viewing a link that looks better with line wrap off, I wouldn't expect viewing that link to change my settings next time I start a new investigation

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this isn't a hill I'm trying to die on, I can forsee situations where either situation is more desirable 🤷

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, same. Let's go with the current state.

public onActivate() {
// Need viz to set options, but setting options will trigger query
this.setStateFromUrl();

if (!this.state.body) {
this.setState({
body: this.getLogsPanel(),
body: this.getLogsPanel({
wrapLogMessage: this.state.wrapLogMessage,
prettifyLogMessage: this.state.wrapLogMessage,
sortOrder: this.state.sortOrder,
}),
});
}

Expand All @@ -51,7 +118,11 @@ export class LogsPanelScene extends SceneObjectBase<LogsPanelSceneState> {
if (newState.logsCount !== prevState.logsCount) {
if (!this.state.body) {
this.setState({
body: this.getLogsPanel(),
body: this.getLogsPanel({
wrapLogMessage: this.state.wrapLogMessage,
prettifyLogMessage: this.state.wrapLogMessage,
sortOrder: this.state.sortOrder,
}),
});
} else {
this.state.body.setState({
Expand Down Expand Up @@ -101,11 +172,11 @@ export class LogsPanelScene extends SceneObjectBase<LogsPanelSceneState> {
}
};

setLogsVizOption(options = {}) {
setLogsVizOption(options: Partial<Options> = {}) {
if (!this.state.body) {
return;
}
if ('sortOrder' in options) {
if ('sortOrder' in options && options.sortOrder !== this.state.body.state.options.sortOrder) {
const $data = sceneGraph.getData(this);
const queryRunner =
$data instanceof SceneQueryRunner ? $data : sceneGraph.findDescendents($data, SceneQueryRunner)[0];
Expand Down Expand Up @@ -136,7 +207,7 @@ export class LogsPanelScene extends SceneObjectBase<LogsPanelSceneState> {
return formattedCount !== undefined ? `Logs (${formattedCount.text}${formattedCount.suffix?.trim()})` : 'Logs';
}

private getLogsPanel() {
private getLogsPanel(options: Partial<Options>) {
const parentModel = this.getParentScene();
const visualizationType = parentModel.state.visualizationType;
const serviceScene = sceneGraph.getAncestor(this, ServiceScene);
Expand All @@ -151,9 +222,12 @@ export class LogsPanelScene extends SceneObjectBase<LogsPanelSceneState> {
.setOption('onClickShowField', this.onClickShowField)
.setOption('onClickHideField', this.onClickHideField)
.setOption('displayedFields', parentModel.state.displayedFields)
.setOption('sortOrder', getLogsPanelSortOrder())
.setOption('wrapLogMessage', Boolean(getLogOption<boolean>('wrapLogMessage', false)))
.setOption('prettifyLogMessage', Boolean(getLogOption<boolean>('wrapLogMessage', false)))
.setOption('sortOrder', options.sortOrder ?? getLogsPanelSortOrderFromStore())
.setOption('wrapLogMessage', options.wrapLogMessage ?? Boolean(getLogOption<boolean>('wrapLogMessage', false)))
.setOption(
'prettifyLogMessage',
options.prettifyLogMessage ?? Boolean(getLogOption<boolean>('wrapLogMessage', false))
)
.setMenu(new PanelMenu({ addExplorationsLink: false }))
.setOption('showLogContextToggle', true)
// @ts-expect-error Requires Grafana 11.5
Expand Down Expand Up @@ -188,9 +262,7 @@ export class LogsPanelScene extends SceneObjectBase<LogsPanelSceneState> {
}

const logsVolumeScene = sceneGraph.findByKeyAndType(this, logsVolumePanelKey, LogsVolumePanel);
if (logsVolumeScene instanceof LogsVolumePanel) {
logsVolumeScene.updateVisibleRange(newLogs);
}
logsVolumeScene.updateVisibleRange(newLogs);
};

private handleShareLogLineClick = (event: MouseEvent<HTMLElement>, row?: LogRowModel) => {
Expand Down
4 changes: 2 additions & 2 deletions src/services/datasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { logger } from './logger';
import { PLUGIN_ID } from './plugin';
import { sanitizeStreamSelector } from './query';
import { LOGS_PANEL_QUERY_REFID } from 'Components/ServiceScene/ServiceScene';
import { getLogsPanelSortOrder } from 'Components/ServiceScene/LogOptionsScene';
import { getLogsPanelSortOrderFromStore, getLogsPanelSortOrderFromURL } from 'Components/ServiceScene/LogOptionsScene';

export const WRAPPED_LOKI_DS_UID = 'wrapped-loki-ds-uid';

Expand Down Expand Up @@ -271,7 +271,7 @@ export class WrappedLokiDatasource extends RuntimeDataSource<DataQuery> {
}

private applyQueryDirection(targets: LokiQuery[]) {
const sortOrder = getLogsPanelSortOrder();
const sortOrder = getLogsPanelSortOrderFromURL() || getLogsPanelSortOrderFromStore();
return targets.map((target) => {
if (target.refId !== LOGS_PANEL_QUERY_REFID) {
return target;
Expand Down
13 changes: 12 additions & 1 deletion src/services/narrowing.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SelectedTableRow } from '../Components/Table/LogLineCellComponent';
import { LogsVisualizationType } from './store';
import { FieldValue, ParserType } from './variables';
import { RawTimeRange } from '@grafana/data';
import { LogsSortOrder, RawTimeRange } from '@grafana/data';
const isObj = (o: unknown): o is object => typeof o === 'object' && o !== null;

function hasProp<K extends PropertyKey>(data: object, prop: K): data is Record<K, unknown> {
Expand Down Expand Up @@ -39,6 +39,17 @@ export function narrowSelectedTableRow(o: unknown): SelectedTableRow | false {
export function narrowLogsVisualizationType(o: unknown): LogsVisualizationType | false {
return typeof o === 'string' && (o === 'logs' || o === 'table') && o;
}
export function narrowLogsSortOrder(o: unknown): LogsSortOrder | false {
if (typeof o === 'string' && o === LogsSortOrder.Ascending.toString()) {
return LogsSortOrder.Ascending;
}

if (typeof o === 'string' && o === LogsSortOrder.Descending.toString()) {
return LogsSortOrder.Descending;
}

return false;
}

export function narrowFieldValue(o: unknown): FieldValue | false {
const narrowed = isObj(o) && hasProp(o, 'value') && hasProp(o, 'parser') && o;
Expand Down
42 changes: 42 additions & 0 deletions tests/exploreServicesBreakDown.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1110,6 +1110,48 @@ test.describe('explore services breakdown page', () => {
);
});

test('logs panel options: url sync', async ({ page }) => {
explorePage.blockAllQueriesExcept({
refIds: ['logsPanelQuery', 'A'],
});

// Check default values
await expect(explorePage.getLogsDirectionNewestFirstLocator()).toBeChecked();
await expect(explorePage.getLogsDirectionOldestFirstLocator()).not.toBeChecked();

await expect(explorePage.getNowrapLocator()).toBeChecked();
await expect(explorePage.getWrapLocator()).not.toBeChecked();

const viewportSize = page.viewportSize();

// Check annotation location
const boundingBoxDesc = await page.getByTestId('data-testid annotation-marker').boundingBox();

// Annotation should be on the right side of the viewport
expect(boundingBoxDesc.x).toBeGreaterThan(viewportSize.width / 2);

// Check non-default values
await explorePage.gotoLogsPanel('Ascending', 'true');

await expect(explorePage.getLogsDirectionNewestFirstLocator()).not.toBeChecked();
await expect(explorePage.getLogsDirectionOldestFirstLocator()).toBeChecked();

await expect(explorePage.getNowrapLocator()).not.toBeChecked();
await expect(explorePage.getWrapLocator()).toBeChecked();

// Check annotation location
const boundingBoxAsc = await page.getByTestId('data-testid annotation-marker').boundingBox();

// Annotation should be on the left side of the viewport
expect(boundingBoxAsc.x).toBeLessThan(viewportSize.width / 2);
});

test('url sharing', async ({ page }) => {
explorePage.blockAllQueriesExcept({ refIds: ['NA'] });
await page.getByLabel('Copy shortened URL').click();
await expect(page.getByText('Shortened link copied to')).toBeVisible();
});

test('panel menu: label name panel should open links in explore', async ({ page, context }) => {
await explorePage.goToLabelsTab();
await page.getByTestId('data-testid Panel menu detected_level').click();
Expand Down
8 changes: 8 additions & 0 deletions tests/fixtures/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,14 @@ export class ExplorePage {
);
}

async gotoLogsPanel(
sortOrder: 'Ascending' | 'Descending' = 'Descending',
wrapLogMessage: 'true' | 'false' = 'false'
) {
const url = `/a/grafana-lokiexplore-app/explore/service/tempo-distributor/logs?patterns=[]&from=now-5m&to=now&var-ds=gdev-loki&var-filters=service_name|=|tempo-distributor&var-fields=&var-levels=&var-metadata=&var-patterns=&var-lineFilter=&timezone=utc&urlColumns=["Time","Line"]&visualizationType="logs"&displayedFields=[]&sortOrder="${sortOrder}"&wrapLogMessage=${wrapLogMessage}`;
await this.page.goto(url);
}

blockAllQueriesExcept(options: {
refIds?: Array<string | RegExp>;
legendFormats?: string[];
Expand Down
Loading