Skip to content

Commit

Permalink
Config: Administrator config - max interval (#843)
Browse files Browse the repository at this point in the history
* feat: add admin config page, max interval UI

---------

Co-authored-by: Matias Chomicki <[email protected]>
  • Loading branch information
gtk-grafana and matyax authored Nov 4, 2024
1 parent 17e9d13 commit e9515e2
Show file tree
Hide file tree
Showing 6 changed files with 236 additions and 6 deletions.
158 changes: 158 additions & 0 deletions src/Components/AppConfig/AppConfig.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { getBackendSrv, locationService } from '@grafana/runtime';
import { AppPluginMeta, GrafanaTheme2, PluginConfigPageProps, PluginMeta, rangeUtil } from '@grafana/data';
import { lastValueFrom } from 'rxjs';
import { css } from '@emotion/css';
import { Button, Field, FieldSet, Input, useStyles2 } from '@grafana/ui';
import React, { ChangeEvent, useState } from 'react';
import { isNumber } from 'lodash';
import { logger } from '../../services/logger';

export type JsonData = {
interval?: string;
};

type State = {
interval: string;
isValid: boolean;
};

// 1 hour minimum
const MIN_INTERVAL_SECONDS = 3600;

interface Props extends PluginConfigPageProps<AppPluginMeta<JsonData>> {}

const AppConfig = ({ plugin }: Props) => {
const styles = useStyles2(getStyles);
const { enabled, pinned, jsonData } = plugin.meta;

const [state, setState] = useState<State>({
interval: jsonData?.interval ?? '',
isValid: isValid(jsonData?.interval ?? ''),
});

const onChangeInterval = (event: ChangeEvent<HTMLInputElement>) => {
const interval = event.target.value.trim();
setState({
...state,
interval,
isValid: isValid(interval),
});
};

return (
<div data-testid={testIds.appConfig.container}>
<FieldSet label="Settings">
<Field
invalid={!isValid(state.interval)}
error={'Interval is invalid. Please enter an interval longer then "60m". For example: 3d, 1w, 1m'}
description={
<span>
The maximum interval that can be selected in the time picker within the Explore Logs app. If empty, users
can select any time range interval in Explore Logs. <br />
Example values: 7d, 24h, 2w
</span>
}
label={'Maximum time picker interval'}
className={styles.marginTop}
>
<Input
width={60}
id="interval"
data-testid={testIds.appConfig.interval}
label={`Max interval`}
value={state?.interval}
placeholder={`7d`}
onChange={onChangeInterval}
/>
</Field>

<div className={styles.marginTop}>
<Button
type="submit"
data-testid={testIds.appConfig.submit}
onClick={() =>
updatePluginAndReload(plugin.meta.id, {
enabled,
pinned,
jsonData: {
interval: state.interval,
},
})
}
disabled={!isValid(state.interval)}
>
Save settings
</Button>
</div>
</FieldSet>
</div>
);
};

const getStyles = (theme: GrafanaTheme2) => ({
colorWeak: css`
color: ${theme.colors.text.secondary};
`,
marginTop: css`
margin-top: ${theme.spacing(3)};
`,
marginTopXl: css`
margin-top: ${theme.spacing(6)};
`,
label: css({
display: 'flex',
alignItems: 'center',
marginBottom: theme.spacing(0.75),
}),
icon: css({
marginLeft: theme.spacing(1),
}),
});

const updatePluginAndReload = async (pluginId: string, data: Partial<PluginMeta<JsonData>>) => {
try {
await updatePlugin(pluginId, data);

// Reloading the page as the changes made here wouldn't be propagated to the actual plugin otherwise.
// This is not ideal, however unfortunately currently there is no supported way for updating the plugin state.
locationService.reload();
} catch (e) {
logger.error('Error while updating the plugin');
}
};

const testIds = {
appConfig: {
container: 'data-testid ac-container',
interval: 'data-testid ac-interval-input',
submit: 'data-testid ac-submit-form',
},
};

export const updatePlugin = async (pluginId: string, data: Partial<PluginMeta>) => {
const response = getBackendSrv().fetch({
url: `/api/plugins/${pluginId}/settings`,
method: 'POST',
data,
});

const dataResponse = await lastValueFrom(response);

return dataResponse.data;
};

const isValid = (interval: string): boolean => {
try {
if (interval) {
const seconds = rangeUtil.intervalToSeconds(interval);
return isNumber(seconds) && seconds >= MIN_INTERVAL_SECONDS;
} else {
// Empty strings are fine
return true;
}
} catch (e) {}

return false;
};

export default AppConfig;
63 changes: 60 additions & 3 deletions src/Components/IndexScene/IndexScene.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';

import { AdHocVariableFilter, SelectableValue } from '@grafana/data';
import { AdHocVariableFilter, AppEvents, AppPluginMeta, rangeUtil, SelectableValue } from '@grafana/data';
import {
AdHocFiltersVariable,
CustomVariable,
Expand All @@ -16,6 +16,8 @@ import {
SceneRefreshPicker,
SceneTimePicker,
SceneTimeRange,
SceneTimeRangeLike,
SceneTimeRangeState,
SceneVariableSet,
VariableValueSelectors,
} from '@grafana/scenes';
Expand Down Expand Up @@ -43,7 +45,7 @@ import { FilterOp } from 'services/filters';
import { getDrilldownSlug, PageSlugs } from '../../services/routing';
import { ServiceSelectionScene } from '../ServiceSelectionScene/ServiceSelectionScene';
import { LoadingPlaceholder } from '@grafana/ui';
import { config, locationService } from '@grafana/runtime';
import { config, getAppEvents, locationService } from '@grafana/runtime';
import {
renderLogQLFieldFilters,
renderLogQLLabelFilters,
Expand All @@ -61,7 +63,10 @@ import {
getValueFromFieldsFilter,
} from '../../services/variableGetters';
import { ToolbarScene } from './ToolbarScene';
import { OptionalRouteMatch } from '../Pages';
import { DEFAULT_TIME_RANGE, OptionalRouteMatch } from '../Pages';
import { plugin } from '../../module';
import { JsonData } from '../AppConfig/AppConfig';
import { reportAppInteraction } from '../../services/analytics';
import { AdHocFilterWithLabels, getDetectedFieldValuesTagValuesProvider } from '../../services/TagValuesProvider';
import { lokiRegularEscape } from '../../services/fields';
import { logger } from '../../services/logger';
Expand Down Expand Up @@ -147,6 +152,58 @@ export class IndexScene extends SceneObjectBase<IndexSceneState> {
this.updatePatterns(newState, getPatternsVariable(this));
})
);

const timeRange = sceneGraph.getTimeRange(this);

this._subs.add(timeRange.subscribeToState(this.limitMaxInterval(timeRange)));
}

/**
* If user selects a time range longer then the max configured interval, show toast and set the previous time range.
* @param timeRange
* @private
*/
private limitMaxInterval(timeRange: SceneTimeRangeLike) {
return (newState: SceneTimeRangeState, prevState: SceneTimeRangeState) => {
const { jsonData } = plugin.meta as AppPluginMeta<JsonData>;
try {
const maxInterval = rangeUtil.intervalToSeconds(jsonData?.interval ?? '');
if (!maxInterval) {
return;
}
const timeRangeInterval = newState.value.to.diff(newState.value.from, 'seconds');
if (timeRangeInterval > maxInterval) {
const prevInterval = prevState.value.to.diff(prevState.value.from, 'seconds');
if (timeRangeInterval <= prevInterval) {
timeRange.setState({
value: prevState.value,
from: prevState.from,
to: prevState.to,
});
} else {
const defaultRange = new SceneTimeRange(DEFAULT_TIME_RANGE);
timeRange.setState({
value: defaultRange.state.value,
from: defaultRange.state.from,
to: defaultRange.state.to,
});
}

const appEvents = getAppEvents();
appEvents.publish({
type: AppEvents.alertWarning.name,
payload: [`Time range interval exceeds maximum interval configured by the administrator.`],
});

reportAppInteraction('all', 'interval_too_long', {
attempted_duration_seconds: timeRangeInterval,
configured_max_interval: maxInterval,
});
}
} catch (e) {
console.error(e);
}
};
}

private setVariableTagValuesProviders() {
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Pages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
export type OptionalRouteProps = Optional<RouteProps, 'labelName' | 'labelValue'>;
export type OptionalRouteMatch = SceneRouteMatch<OptionalRouteProps>;

export const DEFAULT_TIME_RANGE = { from: 'now-15m', to: 'now' };
function getServicesScene(routeMatch: OptionalRouteMatch) {
const DEFAULT_TIME_RANGE = { from: 'now-15m', to: 'now' };
return new EmbeddedScene({
body: new IndexScene({
$timeRange: new SceneTimeRange(DEFAULT_TIME_RANGE),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { IconButton } from '@grafana/ui';
import React from 'react';
import { ExtensionPoints } from 'services/extensions/links';
import { getLokiDatasource } from 'services/scenes';

import LokiLogo from '../../../img/logo.svg';

export interface AddToExplorationButtonState extends SceneObjectState {
Expand Down
11 changes: 10 additions & 1 deletion src/module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,16 @@ const App = lazy(async () => {
return import('Components/App');
});

export const plugin = new AppPlugin<{}>().setRootPage(App);
const AppConfig = lazy(async () => {
return await import('./Components/AppConfig/AppConfig');
});

export const plugin = new AppPlugin<{}>().setRootPage(App).addConfigPage({
title: 'Configuration',
icon: 'cog',
body: AppConfig,
id: 'configuration',
});

for (const linkConfig of linkConfigs) {
plugin.addLink(linkConfig);
Expand Down
7 changes: 6 additions & 1 deletion src/services/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ export const reportAppInteraction = (
export const USER_EVENTS_PAGES = {
service_selection: 'service_selection',
service_details: 'service_details',
all: 'all',
} as const;

type UserEventPagesType = keyof typeof USER_EVENTS_PAGES;
type UserEventActionType =
| keyof (typeof USER_EVENTS_ACTIONS)['service_selection']
| keyof (typeof USER_EVENTS_ACTIONS)['service_details'];
| keyof (typeof USER_EVENTS_ACTIONS)['service_details']
| keyof (typeof USER_EVENTS_ACTIONS)['all'];

export const USER_EVENTS_ACTIONS = {
[USER_EVENTS_PAGES.service_selection]: {
Expand Down Expand Up @@ -69,4 +71,7 @@ export const USER_EVENTS_ACTIONS = {
// Wasm not supported
wasm_not_supported: 'wasm_not_supported',
},
[USER_EVENTS_PAGES.all]: {
interval_too_long: 'interval_too_long',
},
} as const;

0 comments on commit e9515e2

Please sign in to comment.