Dashboards: Add alert and panel icon for dashboards that use Angular plugins (#70951)

* Add angularDeprecationUI feature toggle

* Add angular notice in angular panel header

* Show angular notice for angular datasources

* Show angular notice at the top of the dashboard

* Changed Angular deprecation messages

* Fix angular deprecation alert displayed for new dashboards

* re-generate feature flags

* Removed unnecessary changes

* Add angular deprecation dashboard notice tests

* Add test for angular deprecation panel icon

* Update test suite name

* Moved isAngularDatasourcePlugin to app/features/plugins/angularDeprecation

* Add hasAngularPlugins to DashboardModel

* re-generate feature toggles

* Fix tests

* Fix data source spelling

* Fix typing issues

* Extract plugin type into a separate function

* re-generate feature flags

* reportInteraction on angular dashboard notice dismiss

* re-generate feature flags

* Re-generate feature flags

* lint
This commit is contained in:
Giuseppe Guerra 2023-08-29 16:05:47 +02:00 committed by GitHub
parent 6b9f51c209
commit bf4d025012
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 269 additions and 3 deletions

View File

@ -130,6 +130,7 @@ Experimental features might be changed or removed without prior notice.
| `prometheusConfigOverhaulAuth` | Update the Prometheus configuration page with the new auth component |
| `influxdbSqlSupport` | Enable InfluxDB SQL query language support with new querying UI |
| `noBasicRole` | Enables a new role that has no permissions by default |
| `angularDeprecationUI` | Display new Angular deprecation-related UI features |
## Development feature toggles

View File

@ -120,4 +120,5 @@ export interface FeatureToggles {
influxdbSqlSupport?: boolean;
noBasicRole?: boolean;
alertingNoDataErrorExecution?: boolean;
angularDeprecationUI?: boolean;
}

View File

@ -708,5 +708,12 @@ var (
Owner: grafanaAlertingSquad,
RequiresRestart: true,
},
{
Name: "angularDeprecationUI",
Description: "Display new Angular deprecation-related UI features",
Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaPluginsPlatformSquad,
},
}
)

View File

@ -101,3 +101,4 @@ configurableSchedulerTick,experimental,@grafana/alerting-squad,false,false,true,
influxdbSqlSupport,experimental,@grafana/observability-metrics,false,false,false,false
noBasicRole,experimental,@grafana/grafana-authnz-team,false,false,true,true
alertingNoDataErrorExecution,privatePreview,@grafana/alerting-squad,false,false,true,false
angularDeprecationUI,experimental,@grafana/plugins-platform-backend,false,false,false,true

1 Name Stage Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
101 influxdbSqlSupport experimental @grafana/observability-metrics false false false false
102 noBasicRole experimental @grafana/grafana-authnz-team false false true true
103 alertingNoDataErrorExecution privatePreview @grafana/alerting-squad false false true false
104 angularDeprecationUI experimental @grafana/plugins-platform-backend false false false true

View File

@ -414,4 +414,8 @@ const (
// FlagAlertingNoDataErrorExecution
// Changes how Alerting state manager handles execution of NoData/Error
FlagAlertingNoDataErrorExecution = "alertingNoDataErrorExecution"
// FlagAngularDeprecationUI
// Display new Angular deprecation-related UI features
FlagAngularDeprecationUI = "angularDeprecationUI"
)

View File

@ -16,6 +16,7 @@ import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { getNavModel } from 'app/core/selectors/navModel';
import { PanelModel } from 'app/features/dashboard/state';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { AngularDeprecationNotice } from 'app/features/plugins/angularDeprecation/AngularDeprecationNotice';
import { getPageNavFromSlug, getRootContentNavModel } from 'app/features/storage/StorageFolderPage';
import { DashboardRoutes, KioskMode, StoreState } from 'app/types';
import { PanelEditEnteredEvent, PanelEditExitedEvent } from 'app/types/events';
@ -387,6 +388,9 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
<SubMenu dashboard={dashboard} annotations={dashboard.annotations.list} links={dashboard.links} />
</section>
)}
{config.featureToggles.angularDeprecationUI && dashboard.hasAngularPlugins() && dashboard.uid !== null && (
<AngularDeprecationNotice dashboardUid={dashboard.uid} />
)}
<DashboardGrid
dashboard={dashboard}
isEditable={!!dashboard.meta.canEdit}

View File

@ -0,0 +1,76 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { LoadingState, TimeRange } from '@grafana/data';
import { AngularNotice, PanelHeaderTitleItems } from './PanelHeaderTitleItems';
function renderComponent(angularNoticeOverride?: Partial<AngularNotice>) {
render(
<PanelHeaderTitleItems
data={{
series: [],
state: LoadingState.Done,
timeRange: {} as TimeRange,
}}
panelId={1}
angularNotice={{
...{
show: true,
isAngularDatasource: false,
isAngularPanel: false,
},
...angularNoticeOverride,
}}
/>
);
}
describe('PanelHeaderTitleItems angular deprecation', () => {
const iconSelector = 'angular-deprecation-icon';
it('should render angular warning icon for angular plugins', () => {
renderComponent();
expect(screen.getByTestId(iconSelector)).toBeInTheDocument();
});
it('should not render angular warning icon for non-angular plugins', () => {
renderComponent({ show: false });
expect(screen.queryByTestId(iconSelector)).not.toBeInTheDocument();
});
describe('Tooltip text', () => {
const tests = [
{
name: 'panel',
isAngularPanel: true,
isAngularDatasource: false,
expect: /This panel requires Angular/i,
},
{
name: 'datasource',
isAngularPanel: false,
isAngularDatasource: true,
expect: /This data source requires Angular/i,
},
{
name: 'unknown (generic)',
isAngularPanel: false,
isAngularDatasource: false,
expect: /This panel or data source requires Angular/i,
},
];
tests.forEach((test) => {
it(`should render the correct tooltip depending on plugin type for {test.name}`, async () => {
renderComponent({
isAngularDatasource: test.isAngularDatasource,
isAngularPanel: test.isAngularPanel,
});
await userEvent.hover(screen.getByTestId(iconSelector));
await waitFor(() => {
expect(screen.getByText(test.expect)).toBeInTheDocument();
});
});
});
});
});

View File

@ -8,16 +8,23 @@ import { PanelLinks } from '../PanelLinks';
import { PanelHeaderNotices } from './PanelHeaderNotices';
export interface AngularNotice {
show: boolean;
isAngularPanel: boolean;
isAngularDatasource: boolean;
}
export interface Props {
alertState?: string;
data: PanelData;
panelId: number;
onShowPanelLinks?: () => Array<LinkModel<PanelModel>>;
panelLinks?: DataLink[];
angularNotice?: AngularNotice;
}
export function PanelHeaderTitleItems(props: Props) {
const { alertState, data, panelId, onShowPanelLinks, panelLinks } = props;
const { alertState, data, panelId, onShowPanelLinks, panelLinks, angularNotice } = props;
const styles = useStyles2(getStyles);
// panel health
@ -47,6 +54,15 @@ export function PanelHeaderTitleItems(props: Props) {
</>
);
const message = `This ${pluginType(angularNotice)} requires Angular (deprecated).`;
const angularNoticeTooltip = (
<Tooltip content={message}>
<PanelChrome.TitleItem className={styles.angularNotice} data-testid="angular-deprecation-icon">
<Icon name="exclamation-triangle" size="md" />
</PanelChrome.TitleItem>
</Tooltip>
);
return (
<>
{panelLinks && panelLinks.length > 0 && onShowPanelLinks && (
@ -56,10 +72,21 @@ export function PanelHeaderTitleItems(props: Props) {
{<PanelHeaderNotices panelId={panelId} frames={data.series} />}
{timeshift}
{alertState && alertStateItem}
{angularNotice?.show && angularNoticeTooltip}
</>
);
}
const pluginType = (angularNotice?: AngularNotice): string => {
if (angularNotice?.isAngularPanel) {
return 'panel';
}
if (angularNotice?.isAngularDatasource) {
return 'data source';
}
return 'panel or data source';
};
const getStyles = (theme: GrafanaTheme2) => {
return {
ok: css({
@ -80,5 +107,8 @@ const getStyles = (theme: GrafanaTheme2) => {
color: theme.colors.emphasize(theme.colors.text.link, 0.03),
},
}),
angularNotice: css({
color: theme.colors.warning.text,
}),
};
};

View File

@ -23,6 +23,7 @@ import { DEFAULT_ANNOTATION_COLOR } from '@grafana/ui';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT, REPEAT_DIR_VERTICAL } from 'app/core/constants';
import { contextSrv } from 'app/core/services/context_srv';
import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object';
import { isAngularDatasourcePlugin } from 'app/features/plugins/angularDeprecation/utils';
import { variableAdapters } from 'app/features/variables/adapters';
import { onTimeRangeUpdated } from 'app/features/variables/state/actions';
import { GetVariables, getVariablesByKey } from 'app/features/variables/state/selectors';
@ -1303,6 +1304,13 @@ export class DashboardModel implements TimeModel {
getOriginalDashboard() {
return this.originalDashboard;
}
hasAngularPlugins(): boolean {
return this.panels.some(
(panel) =>
panel.isAngularPlugin() || (panel.datasource?.uid ? isAngularDatasourcePlugin(panel.datasource?.uid) : false)
);
}
}
function isPanelWithLegend(panel: PanelModel): panel is PanelModel & Pick<Required<PanelModel>, 'legend'> {

View File

@ -614,7 +614,7 @@ export class PanelModel implements DataConfigSource, IPanelModel {
}
isAngularPlugin(): boolean {
return (this.plugin && this.plugin.angularPanelCtrl) !== undefined;
return (this.plugin && this.plugin.angularPanelCtrl) !== undefined || (this.plugin?.meta?.angularDetected ?? false);
}
destroy() {

View File

@ -1,10 +1,11 @@
import React from 'react';
import { LinkModel, PanelData, PanelPlugin, renderMarkdown } from '@grafana/data';
import { getTemplateSrv, locationService, reportInteraction } from '@grafana/runtime';
import { config, getTemplateSrv, locationService, reportInteraction } from '@grafana/runtime';
import { PanelPadding } from '@grafana/ui';
import { InspectTab } from 'app/features/inspector/types';
import { getPanelLinksSupplier } from 'app/features/panel/panellinks/linkSuppliers';
import { isAngularDatasourcePlugin } from 'app/features/plugins/angularDeprecation/utils';
import { PanelHeaderTitleItems } from '../dashgrid/PanelHeader/PanelHeaderTitleItems';
import { DashboardModel, PanelModel } from '../state';
@ -83,10 +84,18 @@ export function getPanelChromeProps(props: CommonProps) {
const padding: PanelPadding = props.plugin.noPadding ? 'none' : 'md';
const alertState = props.data.alertState?.state;
const isAngularDatasource = props.panel.datasource?.uid
? isAngularDatasourcePlugin(props.panel.datasource?.uid)
: false;
const isAngularPanel = props.panel.isAngularPlugin();
const showAngularNotice =
(config.featureToggles.angularDeprecationUI ?? false) && (isAngularDatasource || isAngularPanel);
const showTitleItems =
(props.panel.links && props.panel.links.length > 0 && onShowPanelLinks) ||
(props.data.series.length > 0 && props.data.series.some((v) => (v.meta?.notices?.length ?? 0) > 0)) ||
(props.data.request && props.data.request.timeInfo) ||
showAngularNotice ||
alertState;
const titleItems = showTitleItems && (
@ -95,6 +104,11 @@ export function getPanelChromeProps(props: CommonProps) {
data={props.data}
panelId={props.panel.id}
panelLinks={props.panel.links}
angularNotice={{
show: showAngularNotice,
isAngularDatasource,
isAngularPanel,
}}
onShowPanelLinks={onShowPanelLinks}
/>
);

View File

@ -0,0 +1,66 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { reportInteraction } from '@grafana/runtime';
import { AngularDeprecationNotice } from './AngularDeprecationNotice';
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
}));
function localStorageKey(dsUid: string) {
return `grafana.angularDeprecation.dashboardNotice.isDismissed.${dsUid}`;
}
describe('AngularDeprecationNotice', () => {
const noticeText = /This dashboard depends on Angular/i;
const dsUid = 'abc';
afterAll(() => {
jest.resetAllMocks();
});
beforeEach(() => {
jest.clearAllMocks();
window.localStorage.clear();
});
it('should render', () => {
render(<AngularDeprecationNotice dashboardUid={dsUid} />);
expect(screen.getByText(noticeText)).toBeInTheDocument();
});
it('should be dismissable', async () => {
render(<AngularDeprecationNotice dashboardUid={dsUid} />);
const closeButton = screen.getByRole('button');
expect(closeButton).toBeInTheDocument();
await userEvent.click(closeButton);
expect(screen.queryByText(noticeText)).not.toBeInTheDocument();
});
it('should persist dismission status in localstorage', async () => {
render(<AngularDeprecationNotice dashboardUid={dsUid} />);
expect(window.localStorage.getItem(localStorageKey(dsUid))).toBeNull();
const closeButton = screen.getByRole('button');
expect(closeButton).toBeInTheDocument();
await userEvent.click(closeButton);
expect(window.localStorage.getItem(localStorageKey(dsUid))).toBe('true');
});
it('should not re-render alert if already dismissed', () => {
window.localStorage.setItem(localStorageKey(dsUid), 'true');
render(<AngularDeprecationNotice dashboardUid={dsUid} />);
expect(screen.queryByText(noticeText)).not.toBeInTheDocument();
});
it('should call reportInteraction when dismissing', async () => {
render(<AngularDeprecationNotice dashboardUid={dsUid} />);
const closeButton = screen.getByRole('button');
expect(closeButton).toBeInTheDocument();
await userEvent.click(closeButton);
expect(reportInteraction).toHaveBeenCalledWith('angular_deprecation_notice_dismissed');
});
});

View File

@ -0,0 +1,54 @@
import React from 'react';
import { reportInteraction } from '@grafana/runtime';
import { Alert } from '@grafana/ui';
import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider';
const LOCAL_STORAGE_KEY_PREFIX = 'grafana.angularDeprecation.dashboardNotice.isDismissed';
function localStorageKey(dashboardUid: string): string {
return LOCAL_STORAGE_KEY_PREFIX + '.' + dashboardUid;
}
export interface Props {
dashboardUid: string;
}
export function AngularDeprecationNotice({ dashboardUid }: Props) {
return (
<LocalStorageValueProvider<boolean> storageKey={localStorageKey(dashboardUid)} defaultValue={false}>
{(isDismissed, onDismiss) => {
if (isDismissed) {
return null;
}
return (
<div>
<Alert
severity="warning"
title="This dashboard depends on Angular, which is deprecated and will stop working in future releases of Grafana."
onRemove={() => {
reportInteraction('angular_deprecation_notice_dismissed');
onDismiss(true);
}}
>
<div className="markdown-html">
<ul>
<li>
<a
href="https://grafana.com/docs/grafana/latest/developers/angular_deprecation/"
className="external-link"
target="_blank"
rel="noreferrer"
>
Read our deprecation notice and migration advice.
</a>
</li>
</ul>
</div>
</Alert>
</div>
);
}}
</LocalStorageValueProvider>
);
}