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

Pages: Breakdown routes #477

Merged
merged 31 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
78936b3
chore(wip)
gtk-grafana Jun 20, 2024
f672124
Merge remote-tracking branch 'origin/main' into gtk-grafana/issues/35…
gtk-grafana Jun 20, 2024
b4b4af3
chore(pages): update scenes, remove wip code
gtk-grafana Jun 20, 2024
4b35c82
chore(pages): switch to useSceneApp to show failure to cache
gtk-grafana Jun 20, 2024
653a03c
chore(app): refactor
gtk-grafana Jun 24, 2024
e9cfac9
chore(app): remove routes outside scene app
gtk-grafana Jun 24, 2024
d964917
chore(app): add permissions redirect, remove console.oog
gtk-grafana Jun 24, 2024
900952b
chore(app): refactor pages, fix redirectToStart bug
gtk-grafana Jun 24, 2024
45f6e48
feat(routes): wip - add all the routes
gtk-grafana Jun 24, 2024
dd818c7
chore(routes): remove mode/actionView
gtk-grafana Jun 24, 2024
38737f7
Merge remote-tracking branch 'origin/main' into gtk-grafana/issues/35…
gtk-grafana Jun 25, 2024
da8c5bc
chore(routing): reset all variables when routing back to index scene
gtk-grafana Jun 25, 2024
66138f4
chore(breadcrumbs): fix breadcrumb titles
gtk-grafana Jun 25, 2024
86591f2
Merge remote-tracking branch 'origin/main' into gtk-grafana/issues/35…
gtk-grafana Jun 26, 2024
c339450
fix(table): update state from url for new routing
gtk-grafana Jun 26, 2024
a87850a
fix(table): fix copying urls containing spaces
gtk-grafana Jun 26, 2024
8eb0e81
fix(routing): fix stale tab headers
gtk-grafana Jun 26, 2024
d91e562
chore: clean up, document
gtk-grafana Jun 26, 2024
4733a35
chore(routes): refactor to avoid require() of es module not supported…
gtk-grafana Jun 26, 2024
fa121a1
Revert "chore(routes): refactor to avoid require() of es module not s…
gtk-grafana Jun 26, 2024
142c9ab
test(routes): update test url
gtk-grafana Jun 26, 2024
bcc4508
test: remove only
gtk-grafana Jun 26, 2024
b86b52a
chore(routes): remove service def
gtk-grafana Jun 27, 2024
cbd5278
chore(routes): add navigation handlers, redirect back to route for in…
gtk-grafana Jun 27, 2024
3da4446
Merge remote-tracking branch 'origin/main' into gtk-grafana/issues/35…
gtk-grafana Jun 27, 2024
3e01c33
chore: refactor
gtk-grafana Jul 2, 2024
68a0d0a
chore: move layout scene init to constructor
gtk-grafana Jul 2, 2024
b498178
test: add coverage for buildBreakdownUrl
gtk-grafana Jul 2, 2024
27ea0cb
test: add coverage for buildServicesUrl
gtk-grafana Jul 2, 2024
17d88bd
Merge remote-tracking branch 'origin/main' into gtk-grafana/issues/35…
gtk-grafana Jul 2, 2024
c432ddb
Merge branch 'main' into gtk-grafana/issues/351/location-history
gtk-grafana Jul 2, 2024
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
"@emotion/css": "^11.10.6",
"@grafana/data": "^11.0.0",
"@grafana/runtime": "^11.0.0",
"@grafana/scenes": "4.32.0",
"@grafana/scenes": "5.2.0",
gtk-grafana marked this conversation as resolved.
Show resolved Hide resolved
"@grafana/ui": "^11.0.0",
"@playwright/test": "^1.43.1",
"@types/react-table": "^7.7.20",
Expand Down
4 changes: 2 additions & 2 deletions src/Components/App.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import React from 'react';
import { AppRootProps } from '@grafana/data';
import { Routes } from './Routes';
import { LogExplorationView } from './LogExplorationPage';

const PluginPropsContext = React.createContext<AppRootProps | null>(null);

export class App extends React.PureComponent<AppRootProps> {
render() {
return (
<PluginPropsContext.Provider value={this.props}>
<Routes />
<LogExplorationView />
</PluginPropsContext.Provider>
);
}
Expand Down
109 changes: 67 additions & 42 deletions src/Components/IndexScene/IndexScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,15 @@ import {

import { addLastUsedDataSourceToStorage, getLastUsedDataSourceFromStorage } from 'services/store';
import { ServiceScene } from '../ServiceScene/ServiceScene';
import { ServiceSelectionScene, StartingPointSelectedEvent } from '../ServiceSelectionScene/ServiceSelectionScene';
import { LayoutScene } from './LayoutScene';
import { FilterOp } from 'services/filters';
import { SLUGS } from '../../services/routing';
import { getSlug } from '../Pages';
import { ServiceSelectionScene } from '../ServiceSelectionScene/ServiceSelectionScene';
import { LoadingPlaceholder } from '@grafana/ui';
import { locationService } from '@grafana/runtime';

type LogExplorationMode = 'service_selection' | 'service_details';
export type LogExplorationMode = 'service_selection' | 'service_details';

export interface AppliedPattern {
pattern: string;
Expand All @@ -47,16 +51,14 @@ export interface IndexSceneState extends SceneObjectState {
// contentScene is the scene that is displayed in the main body of the index scene - it can be either the service selection or service scene
contentScene?: SceneObject;
gtk-grafana marked this conversation as resolved.
Show resolved Hide resolved
controls: SceneObject[];
body: LayoutScene;
// mode is the current mode of the index scene - it can be either 'service_selection' or 'service_details'
mode?: LogExplorationMode;
gtk-grafana marked this conversation as resolved.
Show resolved Hide resolved
body?: LayoutScene;
initialFilters?: AdHocVariableFilter[];
initialDS?: string;
patterns?: AppliedPattern[];
}

export class IndexScene extends SceneObjectBase<IndexSceneState> {
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['mode', 'patterns'] });
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['patterns'] });

public constructor(state: Partial<IndexSceneState>) {
super({
Expand All @@ -69,7 +71,8 @@ export class IndexScene extends SceneObjectBase<IndexSceneState> {
new SceneTimePicker({}),
new SceneRefreshPicker({}),
],
body: new LayoutScene({}),
// Need to clear patterns state when the class in constructed
patterns: [],
...state,
});

Expand All @@ -78,70 +81,91 @@ export class IndexScene extends SceneObjectBase<IndexSceneState> {

static Component = ({ model }: SceneComponentProps<IndexScene>) => {
const { body } = model.useState();
if (body) {
return <body.Component model={body} />;
}

return <body.Component model={body} />;
return <LoadingPlaceholder text={'Loading...'} />;
};

public onActivate() {
const stateUpdate: Partial<IndexSceneState> = {};
stateUpdate.body = new LayoutScene({});

if (!this.state.contentScene) {
this.setState({ contentScene: getContentScene(this.state.mode) });
stateUpdate.contentScene = getContentScene();
}

// Some scene elements publish this
this.subscribeToEvent(StartingPointSelectedEvent, this._handleStartingPointSelected.bind(this));
this.setState(stateUpdate);
const patternsVariable = sceneGraph.lookupVariable(VAR_PATTERNS, this);
if (patternsVariable instanceof CustomVariable) {
this.updatePatterns(this.state, patternsVariable);
}

this.subscribeToState((newState, oldState) => {
if (newState.mode !== oldState.mode) {
this.setState({ contentScene: getContentScene(newState.mode) });
}
const fieldsVariable = sceneGraph.lookupVariable(VAR_FIELDS, this);
if (fieldsVariable instanceof AdHocFiltersVariable) {
this.syncFieldsWithUrl(fieldsVariable);
}

const patternsVariable = sceneGraph.lookupVariable(VAR_PATTERNS, this);
if (patternsVariable instanceof CustomVariable) {
const patternsLine = renderPatternFilters(newState.patterns ?? []);
patternsVariable.changeValueTo(patternsLine);
}
});
this._subs.add(
this.subscribeToState((newState) => {
const patternsVariable = sceneGraph.lookupVariable(VAR_PATTERNS, this);
if (patternsVariable instanceof CustomVariable) {
this.updatePatterns(newState, patternsVariable);
}
})
);

return () => {
getUrlSyncManager().cleanUp(this);
};
}

/**
* @todo why do we need to manually sync fields, but nothing else?
* @param fieldsVariable
* @private
*/
private syncFieldsWithUrl(fieldsVariable: AdHocFiltersVariable) {
gtk-grafana marked this conversation as resolved.
Show resolved Hide resolved
const location = locationService.getLocation();
const search = new URLSearchParams(location.search);
const filtersFromUrl = search.get('var-fields');

// If the filters aren't in the URL, then they're coming from the cache, set the state to sync with url
if (filtersFromUrl === null) {
fieldsVariable.setState({ filters: [] });
}
}

private updatePatterns(newState: IndexSceneState, patternsVariable: CustomVariable) {
const patternsLine = renderPatternFilters(newState.patterns ?? []);
patternsVariable.changeValueTo(patternsLine);
}

getUrlState() {
return {
mode: this.state.mode,
patterns: this.state.mode === 'service_selection' ? '' : JSON.stringify(this.state.patterns),
patterns: JSON.stringify(this.state.patterns),
};
}

updateFromUrl(values: SceneObjectUrlValues) {
const stateUpdate: Partial<IndexSceneState> = {};
if (values.mode !== this.state.mode) {
const mode: LogExplorationMode = (values.mode as LogExplorationMode) ?? 'service_selection';
stateUpdate.mode = mode;
stateUpdate.contentScene = getContentScene(mode);
}
if (this.state.mode === 'service_selection') {
// Clear patterns on start
stateUpdate.patterns = undefined;
} else if (values.patterns && typeof values.patterns === 'string') {

if (values.patterns && typeof values.patterns === 'string') {
stateUpdate.patterns = JSON.parse(values.patterns) as AppliedPattern[];
}
this.setState(stateUpdate);
}

private _handleStartingPointSelected(evt: StartingPointSelectedEvent) {
this.setState({
mode: 'service_details',
});
this.setState(stateUpdate);
}
}

function getContentScene(mode?: LogExplorationMode) {
if (mode === 'service_details') {
return new ServiceScene({});
function getContentScene() {
const slug = getSlug();
if (slug === SLUGS.explore) {
return new ServiceSelectionScene({});
}
return new ServiceSelectionScene({});

return new ServiceScene({});
}

function getVariableSet(initialDS?: string, initialFilters?: AdHocVariableFilter[]) {
Expand Down Expand Up @@ -195,6 +219,7 @@ function getVariableSet(initialDS?: string, initialFilters?: AdHocVariableFilter
dsVariable,
filterVariable,
fieldsVariable,
// @todo where is patterns being added to the url? Why do we have var-patterns and patterns?
gtk-grafana marked this conversation as resolved.
Show resolved Hide resolved
new CustomVariable({
name: VAR_PATTERNS,
value: '',
Expand Down
31 changes: 19 additions & 12 deletions src/Components/LogExplorationPage.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import React, { useEffect, useMemo } from 'react';
import React, { useEffect } from 'react';

import { SceneTimeRange, getUrlSyncManager } from '@grafana/scenes';
import { IndexScene } from './IndexScene/IndexScene';
const DEFAULT_TIME_RANGE = { from: 'now-15m', to: 'now' };
import { getUrlSyncManager, SceneApp, useSceneApp } from '@grafana/scenes';
import { config } from '@grafana/runtime';
import { Redirect } from 'react-router-dom';
import { makeIndexPage, makeRedirectPage } from './Pages';
import { SLUGS } from '../services/routing';

const getSceneApp = () =>
new SceneApp({
pages: [makeIndexPage(), makeRedirectPage(SLUGS.explore)],
});

export function LogExplorationView() {
const [isInitialized, setIsInitialized] = React.useState(false);
// Must memoize the top-level scene or any route change will re-instantiate all the scene classes
const scene = useMemo(
() =>
new IndexScene({
$timeRange: new SceneTimeRange(DEFAULT_TIME_RANGE),
}),
[]
);

const scene = useSceneApp(getSceneApp);

useEffect(() => {
if (!isInitialized) {
Expand All @@ -22,6 +23,12 @@ export function LogExplorationView() {
}
}, [scene, isInitialized]);

const userPermissions = config.bootData.user.permissions;
const canUseApp = userPermissions?.['grafana-lokiexplore-app:read'] || userPermissions?.['datasources:explore'];
if (!canUseApp) {
return <Redirect to="/" />;
}

if (!isInitialized) {
return null;
}
Expand Down
123 changes: 123 additions & 0 deletions src/Components/Pages.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {
EmbeddedScene,
SceneAppPage,
SceneAppPageLike,
SceneFlexLayout,
SceneRouteMatch,
SceneTimeRange,
} from '@grafana/scenes';
import {
DRILLDOWN_URL_KEYS,
PLUGIN_BASE_URL,
prefixRoute,
ROUTE_DEFINITIONS,
ROUTES,
SERVICE_URL_KEYS,
SLUGS,
} from '../services/routing';
import { PageLayoutType } from '@grafana/data';
import { IndexScene } from './IndexScene/IndexScene';
import { locationService } from '@grafana/runtime';

function getServicesScene() {
const DEFAULT_TIME_RANGE = { from: 'now-15m', to: 'now' };
return new EmbeddedScene({
body: new IndexScene({
$timeRange: new SceneTimeRange(DEFAULT_TIME_RANGE),
}),
});
}

// Index page
export function makeIndexPage() {
return new SceneAppPage({
// Top level breadcrumb
title: 'Logs',
url: prefixRoute(SLUGS.explore),
layout: PageLayoutType.Custom,
preserveUrlKeys: SERVICE_URL_KEYS,
routePath: prefixRoute(SLUGS.explore),
getScene: () => getServicesScene(),
drilldowns: [
{
routePath: ROUTE_DEFINITIONS.logs,
getPage: (routeMatch, parent) => makeBreakdownPage(routeMatch, parent, SLUGS.logs),
defaultRoute: true,
},
{
routePath: ROUTE_DEFINITIONS.labels,
getPage: (routeMatch, parent) => makeBreakdownPage(routeMatch, parent, SLUGS.labels),
},
{
routePath: ROUTE_DEFINITIONS.patterns,
getPage: (routeMatch, parent) => makeBreakdownPage(routeMatch, parent, SLUGS.patterns),
},
{
routePath: ROUTE_DEFINITIONS.fields,
getPage: (routeMatch, parent) => makeBreakdownPage(routeMatch, parent, SLUGS.fields),
},
],
});
}
gtk-grafana marked this conversation as resolved.
Show resolved Hide resolved

// Redirect page
export function makeRedirectPage(to: SLUGS) {
return new SceneAppPage({
title: '',
url: PLUGIN_BASE_URL,
getScene: makeEmptyScene(),
preserveUrlKeys: SERVICE_URL_KEYS,
hideFromBreadcrumbs: true,
$behaviors: [
() => {
locationService.push(prefixRoute(to));
},
],
});
}

function makeEmptyScene(): (routeMatch: SceneRouteMatch) => EmbeddedScene {
return () =>
new EmbeddedScene({
body: new SceneFlexLayout({
direction: 'column',
children: [],
}),
});
}

export function makeBreakdownPage(
routeMatch: SceneRouteMatch<{ service: string }>,
parent: SceneAppPageLike,
slug: SLUGS
): SceneAppPage {
const { service } = extractServiceFromRoute(routeMatch);

return new SceneAppPage({
title: slugToBreadcrumbTitle(slug),
layout: PageLayoutType.Custom,
url: ROUTES[slug](service),
preserveUrlKeys: DRILLDOWN_URL_KEYS,
getParentPage: () => parent,
getScene: () => getServicesScene(),
});
}

export function getSlug() {
const location = locationService.getLocation();
const slug = location.pathname.slice(location.pathname.lastIndexOf('/') + 1, location.pathname.length);
return slug as SLUGS;
gtk-grafana marked this conversation as resolved.
Show resolved Hide resolved
}

export function extractServiceFromRoute(routeMatch: SceneRouteMatch<{ service: string }>): { service: string } {
gtk-grafana marked this conversation as resolved.
Show resolved Hide resolved
const service = routeMatch.params.service;
return { service };
}

function slugToBreadcrumbTitle(slug: SLUGS) {
if (slug === 'fields') {
return 'Detected fields';
}
// capitalize first letter
return slug.charAt(0).toUpperCase() + slug.slice(1);
}
20 changes: 0 additions & 20 deletions src/Components/Routes.tsx

This file was deleted.

Loading