mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Fix conflicts after merging from main
This commit is contained in:
commit
c16244234e
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@ -435,6 +435,7 @@ playwright.config.ts @grafana/plugins-platform-frontend
|
||||
/public/app/features/transformers/timeSeriesTable/ @grafana/dataviz-squad @grafana/app-o11y-visualizations
|
||||
/public/app/features/users/ @grafana/identity-access-team
|
||||
/public/app/features/variables/ @grafana/dashboards-squad
|
||||
/public/app/features/trash-section/ @grafana/grafana-frontend-platform
|
||||
/public/app/plugins/panel/alertlist/ @grafana/alerting-frontend
|
||||
/public/app/plugins/panel/annolist/ @grafana/grafana-frontend-platform
|
||||
/public/app/plugins/panel/barchart/ @grafana/dataviz-squad
|
||||
|
121
.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch
Normal file
121
.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch
Normal file
@ -0,0 +1,121 @@
|
||||
diff --git a/build/GridItem.js b/build/GridItem.js
|
||||
index 0a700da9f1180ca532e32e04dc7ea50f2e67b96c..a2e4673fa1133aeaa4018cc01312ca386c3395f5 100644
|
||||
--- a/build/GridItem.js
|
||||
+++ b/build/GridItem.js
|
||||
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
|
||||
});
|
||||
exports.default = void 0;
|
||||
var _react = _interopRequireDefault(require("react"));
|
||||
+var _reactDOM = require("react-dom");
|
||||
var _propTypes = _interopRequireDefault(require("prop-types"));
|
||||
var _reactDraggable = require("react-draggable");
|
||||
var _reactResizable = require("react-resizable");
|
||||
@@ -147,8 +148,10 @@ class GridItem extends _react.default.Component /*:: <Props, State>*/{
|
||||
const pTop = parentRect.top / transformScale;
|
||||
newPosition.left = cLeft - pLeft + offsetParent.scrollLeft;
|
||||
newPosition.top = cTop - pTop + offsetParent.scrollTop;
|
||||
- this.setState({
|
||||
- dragging: newPosition
|
||||
+ _reactDOM.flushSync(() => {
|
||||
+ this.setState({
|
||||
+ dragging: newPosition
|
||||
+ });
|
||||
});
|
||||
|
||||
// Call callback with this data
|
||||
@@ -213,8 +216,10 @@ class GridItem extends _react.default.Component /*:: <Props, State>*/{
|
||||
top,
|
||||
left
|
||||
};
|
||||
- this.setState({
|
||||
- dragging: newPosition
|
||||
+ _reactDOM.flushSync(() => {
|
||||
+ this.setState({
|
||||
+ dragging: newPosition
|
||||
+ });
|
||||
});
|
||||
|
||||
// Call callback with this data
|
||||
@@ -261,8 +266,10 @@ class GridItem extends _react.default.Component /*:: <Props, State>*/{
|
||||
top,
|
||||
left
|
||||
};
|
||||
- this.setState({
|
||||
- dragging: null
|
||||
+ _reactDOM.flushSync(() => {
|
||||
+ this.setState({
|
||||
+ dragging: null
|
||||
+ });
|
||||
});
|
||||
const {
|
||||
x,
|
||||
@@ -485,8 +492,10 @@ class GridItem extends _react.default.Component /*:: <Props, State>*/{
|
||||
let updatedSize = size;
|
||||
if (node) {
|
||||
updatedSize = (0, _utils.resizeItemInDirection)(handle, position, size, containerWidth);
|
||||
- this.setState({
|
||||
- resizing: handlerName === "onResizeStop" ? null : updatedSize
|
||||
+ _reactDOM.flushSync(() => {
|
||||
+ this.setState({
|
||||
+ resizing: handlerName === "onResizeStop" ? null : updatedSize
|
||||
+ });
|
||||
});
|
||||
}
|
||||
|
||||
diff --git a/lib/GridItem.jsx b/lib/GridItem.jsx
|
||||
index dbe41f92388f19d3e476690fa0ee5584ab9d5bb4..1e4713667cd7dadd6618fe06176804a02ee3ccc2 100644
|
||||
--- a/lib/GridItem.jsx
|
||||
+++ b/lib/GridItem.jsx
|
||||
@@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
import React from "react";
|
||||
+import { flushSync } from "react-dom";
|
||||
import PropTypes from "prop-types";
|
||||
import { DraggableCore } from "react-draggable";
|
||||
import { Resizable } from "react-resizable";
|
||||
@@ -459,7 +460,9 @@ export default class GridItem extends React.Component<Props, State> {
|
||||
const pTop = parentRect.top / transformScale;
|
||||
newPosition.left = cLeft - pLeft + offsetParent.scrollLeft;
|
||||
newPosition.top = cTop - pTop + offsetParent.scrollTop;
|
||||
- this.setState({ dragging: newPosition });
|
||||
+ flushSync(() => {
|
||||
+ this.setState({ dragging: newPosition });
|
||||
+ });
|
||||
|
||||
// Call callback with this data
|
||||
const { x, y } = calcXY(
|
||||
@@ -516,7 +519,9 @@ export default class GridItem extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
const newPosition: PartialPosition = { top, left };
|
||||
- this.setState({ dragging: newPosition });
|
||||
+ flushSync(() => {
|
||||
+ this.setState({ dragging: newPosition });
|
||||
+ });
|
||||
|
||||
// Call callback with this data
|
||||
const { containerPadding } = this.props;
|
||||
@@ -549,7 +554,9 @@ export default class GridItem extends React.Component<Props, State> {
|
||||
const { w, h, i, containerPadding } = this.props;
|
||||
const { left, top } = this.state.dragging;
|
||||
const newPosition: PartialPosition = { top, left };
|
||||
- this.setState({ dragging: null });
|
||||
+ flushSync(() => {
|
||||
+ this.setState({ dragging: null });
|
||||
+ });
|
||||
|
||||
const { x, y } = calcXY(
|
||||
this.getPositionParams(),
|
||||
@@ -605,8 +612,10 @@ export default class GridItem extends React.Component<Props, State> {
|
||||
size,
|
||||
containerWidth
|
||||
);
|
||||
- this.setState({
|
||||
- resizing: handlerName === "onResizeStop" ? null : updatedSize
|
||||
+ flushSync(() => {
|
||||
+ this.setState({
|
||||
+ resizing: handlerName === "onResizeStop" ? null : updatedSize
|
||||
+ });
|
||||
});
|
||||
}
|
||||
|
@ -28,7 +28,6 @@ For more information about feature release stages, refer to [Release life cycle
|
||||
| `featureHighlights` | Highlight Grafana Enterprise features | |
|
||||
| `correlations` | Correlations page | Yes |
|
||||
| `exploreContentOutline` | Content outline sidebar | Yes |
|
||||
| `returnToPrevious` | Enables the return to previous context functionality | Yes |
|
||||
| `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes |
|
||||
| `nestedFolders` | Enable folder nesting | Yes |
|
||||
| `nestedFolderPicker` | Enables the new folder picker to work with nested folders. Requires the nestedFolders feature toggle | Yes |
|
||||
@ -191,6 +190,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `alertingListViewV2` | Enables the new alert list view design |
|
||||
| `notificationBanner` | Enables the notification banner UI and API |
|
||||
| `alertingCentralAlertHistory` | Enables the new central alert history. |
|
||||
| `dashboardRestore` | Enables deleted dashboard restore feature |
|
||||
|
||||
## Development feature toggles
|
||||
|
||||
|
@ -3,9 +3,6 @@ import { e2e } from '../utils';
|
||||
describe('ReturnToPrevious button', () => {
|
||||
beforeEach(() => {
|
||||
e2e.flows.login(Cypress.env('USERNAME'), Cypress.env('PASSWORD'));
|
||||
cy.window().then((win) => {
|
||||
win.localStorage.setItem('grafana.featureToggles', 'returnToPrevious=1');
|
||||
});
|
||||
|
||||
cy.visit('/alerting/list');
|
||||
e2e.components.AlertRules.groupToggle().first().click();
|
||||
|
@ -354,7 +354,7 @@
|
||||
"react-dom": "18.2.0",
|
||||
"react-draggable": "4.4.6",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-grid-layout": "1.4.4",
|
||||
"react-grid-layout": "patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch",
|
||||
"react-highlight-words": "0.20.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-i18next": "^14.0.0",
|
||||
@ -414,7 +414,8 @@
|
||||
"history@4.10.1": "patch:history@npm%3A4.10.1#./.yarn/patches/history-npm-4.10.1-ee217563ae.patch",
|
||||
"history@^4.9.0": "patch:history@npm%3A4.10.1#./.yarn/patches/history-npm-4.10.1-ee217563ae.patch",
|
||||
"redux": "^5.0.0",
|
||||
"@storybook/blocks@npm:8.0.10": "patch:@storybook/blocks@npm%3A8.0.10#~/.yarn/patches/@storybook-blocks-npm-8.0.10-6f477cd35f.patch"
|
||||
"@storybook/blocks@npm:8.0.10": "patch:@storybook/blocks@npm%3A8.0.10#~/.yarn/patches/@storybook-blocks-npm-8.0.10-6f477cd35f.patch",
|
||||
"react-grid-layout": "patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
|
@ -971,7 +971,7 @@ describe('getLinksSupplier', () => {
|
||||
});
|
||||
it('handles link click handlers', () => {
|
||||
const onClickSpy = jest.fn();
|
||||
const replaceSpy = jest.fn();
|
||||
const replaceSpy = jest.fn().mockImplementation((value, vars, format) => value);
|
||||
const f0 = createDataFrame({
|
||||
name: 'A',
|
||||
fields: [
|
||||
@ -1008,8 +1008,8 @@ describe('getLinksSupplier', () => {
|
||||
|
||||
links[0].onClick!({});
|
||||
|
||||
expect(onClickSpy).toBeCalledTimes(1);
|
||||
expect(replaceSpy).toBeCalledTimes(4);
|
||||
expect(onClickSpy).toHaveBeenCalledTimes(1);
|
||||
expect(replaceSpy).toHaveBeenCalledTimes(5);
|
||||
// check that onClick variable replacer has scoped vars bound to it
|
||||
expect(replaceSpy.mock.calls[1][1]).toHaveProperty('foo', { text: 'bar', value: 'bar' });
|
||||
});
|
||||
@ -1057,6 +1057,49 @@ describe('getLinksSupplier', () => {
|
||||
expect(replaceSpy.mock.calls[1][1]).toHaveProperty('foo', { text: 'bar', value: 'bar' });
|
||||
});
|
||||
});
|
||||
|
||||
it('handles dynamic links with onclick handler', () => {
|
||||
const replaceSpy = jest.fn().mockReturnValue('url interpolated 10');
|
||||
const onClickUrlSpy = jest.fn();
|
||||
const scopedVars = { foo: { text: 'bar', value: 'bar' } };
|
||||
const f0 = createDataFrame({
|
||||
name: 'A',
|
||||
fields: [
|
||||
{
|
||||
name: 'message',
|
||||
type: FieldType.string,
|
||||
config: {
|
||||
links: [
|
||||
{
|
||||
url: 'should be ignored',
|
||||
onClick: (evt) => {
|
||||
onClickUrlSpy();
|
||||
evt.replaceVariables?.('${foo}');
|
||||
},
|
||||
title: 'title to be interpolated',
|
||||
},
|
||||
{
|
||||
url: 'should not be ignored',
|
||||
title: 'title to be interpolated',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const supplier = getLinksSupplier(f0, f0.fields[0], scopedVars, replaceSpy);
|
||||
const links = supplier({});
|
||||
links[0].onClick!({});
|
||||
|
||||
expect(onClickUrlSpy).toHaveBeenCalledTimes(1);
|
||||
expect(links.length).toBe(2);
|
||||
expect(links[0].href).toEqual('url interpolated 10');
|
||||
expect(links[0].onClick).toBeDefined();
|
||||
expect(replaceSpy).toHaveBeenCalledTimes(5);
|
||||
// check that onClick variable replacer has scoped vars bound to it
|
||||
expect(replaceSpy.mock.calls[1][1]).toHaveProperty('foo', { text: 'bar', value: 'bar' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyRawFieldOverrides', () => {
|
||||
|
@ -468,9 +468,23 @@ export const getLinksSupplier =
|
||||
|
||||
let linkModel: LinkModel<Field>;
|
||||
|
||||
let href =
|
||||
link.onClick || !link.onBuildUrl
|
||||
? link.url
|
||||
: link.onBuildUrl({
|
||||
origin: field,
|
||||
replaceVariables: boundReplaceVariables,
|
||||
});
|
||||
|
||||
if (href) {
|
||||
href = locationUtil.assureBaseUrl(href.replace(/\n/g, ''));
|
||||
href = replaceVariables(href, dataLinkScopedVars, VariableFormatID.UriEncode);
|
||||
href = locationUtil.processUrl(href);
|
||||
}
|
||||
|
||||
if (link.onClick) {
|
||||
linkModel = {
|
||||
href: link.url,
|
||||
href,
|
||||
title: replaceVariables(link.title || '', dataLinkScopedVars),
|
||||
target: link.targetBlank ? '_blank' : undefined,
|
||||
onClick: (evt: MouseEvent, origin: Field) => {
|
||||
@ -483,19 +497,6 @@ export const getLinksSupplier =
|
||||
origin: field,
|
||||
};
|
||||
} else {
|
||||
let href = link.onBuildUrl
|
||||
? link.onBuildUrl({
|
||||
origin: field,
|
||||
replaceVariables: boundReplaceVariables,
|
||||
})
|
||||
: link.url;
|
||||
|
||||
if (href) {
|
||||
href = locationUtil.assureBaseUrl(href.replace(/\n/g, ''));
|
||||
href = replaceVariables(href, dataLinkScopedVars, VariableFormatID.UriEncode);
|
||||
href = locationUtil.processUrl(href);
|
||||
}
|
||||
|
||||
linkModel = {
|
||||
href,
|
||||
title: replaceVariables(link.title || '', dataLinkScopedVars),
|
||||
|
@ -44,7 +44,6 @@ export interface FeatureToggles {
|
||||
disableSecretsCompatibility?: boolean;
|
||||
logRequestsInstrumentedAsUnknown?: boolean;
|
||||
topnav?: boolean;
|
||||
returnToPrevious?: boolean;
|
||||
grpcServer?: boolean;
|
||||
unifiedStorage?: boolean;
|
||||
dualWritePlaylistsMode2?: boolean;
|
||||
|
@ -132,6 +132,7 @@
|
||||
"@testing-library/jest-dom": "6.4.2",
|
||||
"@testing-library/react": "15.0.2",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/chance": "1.1.6",
|
||||
"@types/common-tags": "^1.8.0",
|
||||
"@types/d3": "7.4.3",
|
||||
"@types/hoist-non-react-statics": "3.3.5",
|
||||
@ -158,6 +159,7 @@
|
||||
"@types/testing-library__jest-dom": "5.14.9",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/uuid": "9.0.8",
|
||||
"chance": "1.1.11",
|
||||
"common-tags": "1.8.2",
|
||||
"core-js": "3.37.0",
|
||||
"css-loader": "7.1.1",
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { auto } from '@popperjs/core';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { Meta, StoryFn } from '@storybook/react';
|
||||
import Chance from 'chance';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { SelectableValue, toIconName } from '@grafana/data';
|
||||
@ -12,6 +13,26 @@ import mdx from './Select.mdx';
|
||||
import { generateOptions, generateThousandsOfOptions } from './mockOptions';
|
||||
import { SelectCommonProps } from './types';
|
||||
|
||||
const chance = new Chance();
|
||||
|
||||
const manyGroupedOptions = [
|
||||
{ label: 'Foo', value: '1' },
|
||||
{
|
||||
label: 'Animals',
|
||||
options: new Array(100).fill(0).map((_, i) => {
|
||||
const animal = chance.animal();
|
||||
return { label: animal, value: animal };
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'People',
|
||||
options: new Array(100).fill(0).map((_, i) => {
|
||||
const person = chance.name();
|
||||
return { label: person, value: person };
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Forms/Select',
|
||||
component: Select,
|
||||
@ -242,6 +263,26 @@ export const MultiSelectWithOptionGroups: StoryFn = (args) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const MultiSelectWithOptionGroupsVirtualized: StoryFn = (args) => {
|
||||
const [value, setValue] = useState<string[]>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MultiSelect
|
||||
options={manyGroupedOptions}
|
||||
virtualized
|
||||
value={value}
|
||||
onChange={(v) => {
|
||||
setValue(v.map((v) => v.value!));
|
||||
action('onChange')(v);
|
||||
}}
|
||||
prefix={getPrefix(args.icon)}
|
||||
{...args}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const MultiSelectBasic: StoryFn = (args) => {
|
||||
const [value, setValue] = useState<Array<SelectableValue<string>>>([]);
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { cx } from '@emotion/css';
|
||||
import { max } from 'lodash';
|
||||
import React, { RefCallback, useEffect, useRef } from 'react';
|
||||
import React, { RefCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { MenuListProps } from 'react-select';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
|
||||
@ -57,8 +57,18 @@ export const VirtualizedSelectMenu = ({
|
||||
const styles = getSelectStyles(theme);
|
||||
const listRef = useRef<List>(null);
|
||||
|
||||
const focusedIndex = options.findIndex((option: SelectableValue<unknown>) => option.value === focusedOption?.value);
|
||||
// we need to check for option groups (categories)
|
||||
// these are top level options with child options
|
||||
// if they exist, flatten the list of options
|
||||
const flattenedOptions = useMemo(
|
||||
() => options.flatMap((option) => (option.options ? [option, ...option.options] : [option])),
|
||||
[options]
|
||||
);
|
||||
|
||||
// scroll the focused option into view when navigating with keyboard
|
||||
const focusedIndex = flattenedOptions.findIndex(
|
||||
(option: SelectableValue<unknown>) => option.value === focusedOption?.value
|
||||
);
|
||||
useEffect(() => {
|
||||
listRef.current?.scrollToItem(focusedIndex);
|
||||
}, [focusedIndex]);
|
||||
@ -67,10 +77,23 @@ export const VirtualizedSelectMenu = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const longestOption = max(options.map((option) => option.label?.length)) ?? 0;
|
||||
// flatten the children to account for any categories
|
||||
// these will have array children that are the individual options
|
||||
const flattenedChildren = children.flatMap((child) => {
|
||||
if (hasArrayChildren(child)) {
|
||||
// need to remove the children from the category else they end up in the DOM twice
|
||||
const childWithoutChildren = React.cloneElement(child, {
|
||||
children: null,
|
||||
});
|
||||
return [childWithoutChildren, ...child.props.children];
|
||||
}
|
||||
return [child];
|
||||
});
|
||||
|
||||
const longestOption = max(flattenedOptions.map((option) => option.label?.length)) ?? 0;
|
||||
const widthEstimate =
|
||||
longestOption * VIRTUAL_LIST_WIDTH_ESTIMATE_MULTIPLIER + VIRTUAL_LIST_PADDING * 2 + VIRTUAL_LIST_WIDTH_EXTRA;
|
||||
const heightEstimate = Math.min(options.length * VIRTUAL_LIST_ITEM_HEIGHT, maxHeight);
|
||||
const heightEstimate = Math.min(flattenedChildren.length * VIRTUAL_LIST_ITEM_HEIGHT, maxHeight);
|
||||
|
||||
return (
|
||||
<List
|
||||
@ -79,14 +102,20 @@ export const VirtualizedSelectMenu = ({
|
||||
height={heightEstimate}
|
||||
width={widthEstimate}
|
||||
aria-label="Select options menu"
|
||||
itemCount={children.length}
|
||||
itemCount={flattenedChildren.length}
|
||||
itemSize={VIRTUAL_LIST_ITEM_HEIGHT}
|
||||
>
|
||||
{({ index, style }) => <div style={{ ...style, overflow: 'hidden' }}>{children[index]}</div>}
|
||||
{({ index, style }) => <div style={{ ...style, overflow: 'hidden' }}>{flattenedChildren[index]}</div>}
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
// check if a child has array children (and is therefore a react-select group)
|
||||
// we need to flatten these so the correct count and elements are passed to the virtualized list
|
||||
const hasArrayChildren = (child: React.ReactNode) => {
|
||||
return React.isValidElement(child) && Array.isArray(child.props.children);
|
||||
};
|
||||
|
||||
VirtualizedSelectMenu.displayName = 'VirtualizedSelectMenu';
|
||||
|
||||
interface SelectMenuOptionProps<T> {
|
||||
|
@ -315,8 +315,8 @@ func (s *Service) prepareInstanceSettings(ctx context.Context, pluginContext bac
|
||||
// Make sure it is a known plugin type
|
||||
p, found := s.pluginStore.Plugin(ctx, settings.Type)
|
||||
if !found {
|
||||
return nil, errutil.BadRequest("datasource.unknownPlugin",
|
||||
errutil.WithPublicMessage(fmt.Sprintf("plugin '%s' not found", settings.Type)))
|
||||
// Ignore non-existing plugins for the time being
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// When the APIVersion is set, the client must also implement AdmissionHandler
|
||||
|
@ -206,14 +206,6 @@ var (
|
||||
Expression: "true", // enabled by default
|
||||
Owner: grafanaFrontendPlatformSquad,
|
||||
},
|
||||
{
|
||||
Name: "returnToPrevious",
|
||||
Description: "Enables the return to previous context functionality",
|
||||
Stage: FeatureStageGeneralAvailability,
|
||||
FrontendOnly: true,
|
||||
Expression: "true", // enabled by default
|
||||
Owner: grafanaFrontendPlatformSquad,
|
||||
},
|
||||
{
|
||||
Name: "grpcServer",
|
||||
Description: "Run the GRPC server",
|
||||
@ -1287,7 +1279,6 @@ var (
|
||||
Description: "Enables deleted dashboard restore feature",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaFrontendPlatformSquad,
|
||||
HideFromDocs: true,
|
||||
HideFromAdminPage: true,
|
||||
},
|
||||
{
|
||||
|
@ -25,7 +25,6 @@ scenes,experimental,@grafana/dashboards-squad,false,false,true
|
||||
disableSecretsCompatibility,experimental,@grafana/hosted-grafana-team,false,true,false
|
||||
logRequestsInstrumentedAsUnknown,experimental,@grafana/hosted-grafana-team,false,false,false
|
||||
topnav,deprecated,@grafana/grafana-frontend-platform,false,false,false
|
||||
returnToPrevious,GA,@grafana/grafana-frontend-platform,false,false,true
|
||||
grpcServer,preview,@grafana/grafana-app-platform-squad,false,false,false
|
||||
unifiedStorage,experimental,@grafana/grafana-app-platform-squad,false,true,false
|
||||
dualWritePlaylistsMode2,experimental,@grafana/search-and-storage,false,false,false
|
||||
|
|
@ -111,10 +111,6 @@ const (
|
||||
// Enables topnav support in external plugins. The new Grafana navigation cannot be disabled.
|
||||
FlagTopnav = "topnav"
|
||||
|
||||
// FlagReturnToPrevious
|
||||
// Enables the return to previous context functionality
|
||||
FlagReturnToPrevious = "returnToPrevious"
|
||||
|
||||
// FlagGrpcServer
|
||||
// Run the GRPC server
|
||||
FlagGrpcServer = "grpcServer"
|
||||
|
@ -60,7 +60,8 @@
|
||||
"metadata": {
|
||||
"name": "returnToPrevious",
|
||||
"resourceVersion": "1716448665531",
|
||||
"creationTimestamp": "2024-05-23T07:17:45Z"
|
||||
"creationTimestamp": "2024-05-23T07:17:45Z",
|
||||
"deletionTimestamp": "2024-05-27T09:59:33Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables the return to previous context functionality",
|
||||
@ -587,15 +588,17 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "dashboardRestore",
|
||||
"resourceVersion": "1716448665531",
|
||||
"creationTimestamp": "2024-05-23T07:17:45Z"
|
||||
"resourceVersion": "1716564259132",
|
||||
"creationTimestamp": "2024-05-23T07:17:45Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-05-24 15:24:19.132272 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables deleted dashboard restore feature",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/grafana-frontend-platform",
|
||||
"hideFromAdminPage": true,
|
||||
"hideFromDocs": true
|
||||
"hideFromAdminPage": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2203,26 +2206,19 @@
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "influxdbRunQueriesInParallel",
|
||||
"resourceVersion": "1716448665531",
|
||||
"creationTimestamp": "2024-05-23T07:17:45Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables running InfluxDB Influxql queries in parallel",
|
||||
"stage": "privatePreview",
|
||||
"codeowner": "@grafana/observability-metrics"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "disableSSEDataplane",
|
||||
"resourceVersion": "1716448665531",
|
||||
"creationTimestamp": "2024-05-23T07:17:45Z"
|
||||
"name": "dashboardRestore",
|
||||
"resourceVersion": "1716563559003",
|
||||
"creationTimestamp": "2024-02-20T18:50:41Z",
|
||||
"deletionTimestamp": "2024-05-24T15:24:19Z",
|
||||
"annotations": {
|
||||
"grafana.app/updatedTimestamp": "2024-05-24 15:12:39.003245 +0000 UTC"
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"description": "Disables dataplane specific processing in server side expressions.",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/observability-metrics"
|
||||
"codeowner": "@grafana/grafana-frontend-platform",
|
||||
"hideFromAdminPage": true
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -2277,6 +2273,30 @@
|
||||
"codeowner": "@grafana/alerting-squad",
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "disableSSEDataplane",
|
||||
"resourceVersion": "1716816471156",
|
||||
"creationTimestamp": "2024-05-27T13:27:51Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Disables dataplane specific processing in server side expressions.",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/observability-metrics"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "influxdbRunQueriesInParallel",
|
||||
"resourceVersion": "1716816471156",
|
||||
"creationTimestamp": "2024-05-27T13:27:51Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables running InfluxDB Influxql queries in parallel",
|
||||
"stage": "privatePreview",
|
||||
"codeowner": "@grafana/observability-metrics"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -353,6 +353,15 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt
|
||||
Icon: "library-panel",
|
||||
})
|
||||
}
|
||||
|
||||
if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagDashboardRestore) && hasAccess(ac.EvalPermission(dashboards.ActionDashboardsDelete)) {
|
||||
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
|
||||
Text: "Trash",
|
||||
SubTitle: "Any items remaining in the Trash for more than 30 days will be automatically deleted",
|
||||
Id: "dashboards/trash",
|
||||
Url: s.cfg.AppSubURL + "/dashboard/trash",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if hasAccess(ac.EvalPermission(dashboards.ActionDashboardsCreate)) {
|
||||
|
@ -164,7 +164,7 @@ func (ng *AlertNG) init() error {
|
||||
// If enabled, configure the remote Alertmanager.
|
||||
// - If several toggles are enabled, the order of precedence is RemoteOnly, RemotePrimary, RemoteSecondary
|
||||
// - If no toggles are enabled, we default to using only the internal Alertmanager
|
||||
// We currently support only remote secondary mode, so in case other toggles are enabled we fall back to remote secondary.
|
||||
// We currently do not support remote primary mode, so we fall back to remote secondary.
|
||||
var overrides []notifier.Option
|
||||
moaLogger := log.New("ngalert.multiorg.alertmanager")
|
||||
remoteOnly := ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertmanagerRemoteOnly)
|
||||
@ -172,7 +172,35 @@ func (ng *AlertNG) init() error {
|
||||
remoteSecondary := ng.FeatureToggles.IsEnabled(initCtx, featuremgmt.FlagAlertmanagerRemoteSecondary)
|
||||
if ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Enable {
|
||||
switch {
|
||||
case remoteOnly, remotePrimary:
|
||||
case remoteOnly:
|
||||
ng.Log.Debug("Starting Grafana with remote only mode enabled")
|
||||
m := ng.Metrics.GetRemoteAlertmanagerMetrics()
|
||||
m.Info.WithLabelValues(metrics.ModeRemoteOnly).Set(1)
|
||||
|
||||
// This function will be used by the MOA to create new Alertmanagers.
|
||||
override := notifier.WithAlertmanagerOverride(func(_ notifier.OrgAlertmanagerFactory) notifier.OrgAlertmanagerFactory {
|
||||
return func(ctx context.Context, orgID int64) (notifier.Alertmanager, error) {
|
||||
// Create remote Alertmanager.
|
||||
cfg := remote.AlertmanagerConfig{
|
||||
BasicAuthPassword: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.Password,
|
||||
DefaultConfig: ng.Cfg.UnifiedAlerting.DefaultConfiguration,
|
||||
OrgID: orgID,
|
||||
TenantID: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.TenantID,
|
||||
URL: ng.Cfg.UnifiedAlerting.RemoteAlertmanager.URL,
|
||||
PromoteConfig: true,
|
||||
}
|
||||
remoteAM, err := createRemoteAlertmanager(cfg, ng.KVStore, ng.SecretsService.Decrypt, m)
|
||||
if err != nil {
|
||||
moaLogger.Error("Failed to create remote Alertmanager", "err", err)
|
||||
return nil, err
|
||||
}
|
||||
return remoteAM, nil
|
||||
}
|
||||
})
|
||||
|
||||
overrides = append(overrides, override)
|
||||
|
||||
case remotePrimary:
|
||||
ng.Log.Warn("Only remote secondary mode is supported at the moment, falling back to remote secondary")
|
||||
fallthrough
|
||||
|
||||
|
@ -179,7 +179,7 @@ func (p *EnvVarsProvider) pluginSettingsEnvVars(pluginID string) []string {
|
||||
|
||||
func (p *EnvVarsProvider) envVar(key, value string) string {
|
||||
if strings.Contains(value, "\x00") {
|
||||
p.logger.Error("Variable with key '%s' contains a nil character", key)
|
||||
p.logger.Error("Variable with key '%s' contains NUL", key)
|
||||
}
|
||||
return fmt.Sprintf("%s=%s", key, value)
|
||||
}
|
||||
|
@ -15,16 +15,19 @@ const (
|
||||
kvStoreType = "extsvc-token"
|
||||
// #nosec G101 - this is not a hardcoded secret
|
||||
tokenNamePrefix = "extsvc-token"
|
||||
|
||||
maxTokenGenRetries = 10
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCannotBeDeleted = errutil.BadRequest("extsvcaccounts.ErrCannotBeDeleted", errutil.WithPublicMessage("external service account cannot be deleted"))
|
||||
ErrCannotBeUpdated = errutil.BadRequest("extsvcaccounts.ErrCannotBeUpdated", errutil.WithPublicMessage("external service account cannot be updated"))
|
||||
ErrCannotCreateToken = errutil.BadRequest("extsvcaccounts.ErrCannotCreateToken", errutil.WithPublicMessage("cannot add external service account token"))
|
||||
ErrCannotDeleteToken = errutil.BadRequest("extsvcaccounts.ErrCannotDeleteToken", errutil.WithPublicMessage("cannot delete external service account token"))
|
||||
ErrCannotListTokens = errutil.BadRequest("extsvcaccounts.ErrCannotListTokens", errutil.WithPublicMessage("cannot list external service account tokens"))
|
||||
ErrCredentialsNotFound = errutil.NotFound("extsvcaccounts.credentials-not-found")
|
||||
ErrInvalidName = errutil.BadRequest("extsvcaccounts.ErrInvalidName", errutil.WithPublicMessage("only external service account names can be prefixed with 'extsvc-'"))
|
||||
ErrCannotBeDeleted = errutil.BadRequest("extsvcaccounts.ErrCannotBeDeleted", errutil.WithPublicMessage("external service account cannot be deleted"))
|
||||
ErrCannotBeUpdated = errutil.BadRequest("extsvcaccounts.ErrCannotBeUpdated", errutil.WithPublicMessage("external service account cannot be updated"))
|
||||
ErrCannotCreateToken = errutil.BadRequest("extsvcaccounts.ErrCannotCreateToken", errutil.WithPublicMessage("cannot add external service account token"))
|
||||
ErrCannotDeleteToken = errutil.BadRequest("extsvcaccounts.ErrCannotDeleteToken", errutil.WithPublicMessage("cannot delete external service account token"))
|
||||
ErrCannotListTokens = errutil.BadRequest("extsvcaccounts.ErrCannotListTokens", errutil.WithPublicMessage("cannot list external service account tokens"))
|
||||
ErrCredentialsGenFailed = errutil.Internal("extsvcaccounts.ErrCredentialsGenFailed")
|
||||
ErrCredentialsNotFound = errutil.NotFound("extsvcaccounts.ErrCredentialsNotFound")
|
||||
ErrInvalidName = errutil.BadRequest("extsvcaccounts.ErrInvalidName", errutil.WithPublicMessage("only external service account names can be prefixed with 'extsvc-'"))
|
||||
|
||||
extsvcuser = &user.SignedInUser{
|
||||
OrgID: extsvcauth.TmpOrgID,
|
||||
|
@ -355,7 +355,7 @@ func (esa *ExtSvcAccountsService) getExtSvcAccountToken(ctx context.Context, org
|
||||
|
||||
// Generate token
|
||||
ctxLogger.Info("Generate new service account token", "service", extSvcSlug, "orgID", orgID)
|
||||
newKeyInfo, err := satokengen.New(extSvcSlug)
|
||||
newKeyInfo, err := genTokenWithRetries(ctxLogger, extSvcSlug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@ -380,6 +380,52 @@ func (esa *ExtSvcAccountsService) getExtSvcAccountToken(ctx context.Context, org
|
||||
return newKeyInfo.ClientSecret, nil
|
||||
}
|
||||
|
||||
func genTokenWithRetries(ctxLogger log.Logger, extSvcSlug string) (satokengen.KeyGenResult, error) {
|
||||
var newKeyInfo satokengen.KeyGenResult
|
||||
var err error
|
||||
retry := 0
|
||||
for retry < maxTokenGenRetries {
|
||||
newKeyInfo, err = satokengen.New(extSvcSlug)
|
||||
if err != nil {
|
||||
return satokengen.KeyGenResult{}, err
|
||||
}
|
||||
|
||||
if !strings.Contains(newKeyInfo.ClientSecret, "\x00") {
|
||||
return newKeyInfo, nil
|
||||
}
|
||||
|
||||
retry++
|
||||
|
||||
ctxLogger.Warn("Generated a token containing NUL, retrying",
|
||||
"service", extSvcSlug,
|
||||
"retry", retry,
|
||||
)
|
||||
// On first retry, log the token parts that contain a nil byte
|
||||
if retry == 1 {
|
||||
logTokenNULParts(ctxLogger, extSvcSlug, newKeyInfo.ClientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
return satokengen.KeyGenResult{}, ErrCredentialsGenFailed.Errorf("Failed to generate a token for %s", extSvcSlug)
|
||||
}
|
||||
|
||||
// logTokenNULParts logs a warning if the external service token contains a nil byte
|
||||
// Tokens normally have 3 parts "gl+serviceID_secret_checksum"
|
||||
// Log the part of the generated token that contains a nil byte
|
||||
func logTokenNULParts(ctxLogger log.Logger, extSvcSlug string, token string) {
|
||||
parts := strings.Split(token, "_")
|
||||
for i := range parts {
|
||||
if strings.Contains(parts[i], "\x00") {
|
||||
ctxLogger.Warn("Token contains NUL",
|
||||
"service", extSvcSlug,
|
||||
"part", i,
|
||||
"part_len", len(parts[i]),
|
||||
"parts_count", len(parts),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetExtSvcCredentials get the credentials of an External Service from an encrypted storage
|
||||
func (esa *ExtSvcAccountsService) GetExtSvcCredentials(ctx context.Context, orgID int64, extSvcSlug string) (*Credentials, error) {
|
||||
ctxLogger := esa.logger.FromContext(ctx)
|
||||
@ -391,6 +437,10 @@ func (esa *ExtSvcAccountsService) GetExtSvcCredentials(ctx context.Context, orgI
|
||||
if !ok {
|
||||
return nil, ErrCredentialsNotFound.Errorf("No credential found for in store %v", extSvcSlug)
|
||||
}
|
||||
if strings.Contains(token, "\x00") {
|
||||
ctxLogger.Warn("Loaded token from store containing NUL", "service", extSvcSlug)
|
||||
logTokenNULParts(ctxLogger, extSvcSlug, token)
|
||||
}
|
||||
return &Credentials{Secret: token}, nil
|
||||
}
|
||||
|
||||
|
@ -760,6 +760,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/datasources/uid/{uid}/lbac/teams": {
|
||||
"put": {
|
||||
"tags": [
|
||||
"enterprise"
|
||||
],
|
||||
"summary": "Updates LBAC rules for a team.",
|
||||
"operationId": "updateTeamLBACRulesApi",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"name": "uid",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/okResponse"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/responses/badRequestError"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/responses/unauthorisedError"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/responses/forbiddenError"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFoundError"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/responses/internalServerError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/datasources/{dataSourceUID}/cache": {
|
||||
"get": {
|
||||
"description": "get cache config for a single data source",
|
||||
|
@ -4315,6 +4315,40 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/datasources/uid/{uid}/lbac/teams": {
|
||||
"get": {
|
||||
"summary": "Retrieves LBAC rules for a team.",
|
||||
"operationId": "getTeamLBACRulesApiResponse",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"name": "uid",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/responses/okResponse"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/responses/badRequestError"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/responses/unauthorisedError"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/responses/forbiddenError"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/responses/notFoundError"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/responses/internalServerError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/datasources/uid/{uid}/resources/{datasource_proxy_route}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
@ -5,7 +5,6 @@ import React, { PropsWithChildren, useEffect } from 'react';
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { locationSearchToObject, locationService } from '@grafana/runtime';
|
||||
import { useStyles2, LinkButton, useTheme2 } from '@grafana/ui';
|
||||
import config from 'app/core/config';
|
||||
import { useGrafana } from 'app/core/context/GrafanaContext';
|
||||
import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange';
|
||||
import store from 'app/core/store';
|
||||
@ -55,8 +54,7 @@ export function AppChrome({ children }: Props) {
|
||||
|
||||
const { pathname, search } = locationService.getLocation();
|
||||
const url = pathname + search;
|
||||
const shouldShowReturnToPrevious =
|
||||
config.featureToggles.returnToPrevious && state.returnToPrevious && url !== state.returnToPrevious.href;
|
||||
const shouldShowReturnToPrevious = state.returnToPrevious && url !== state.returnToPrevious.href;
|
||||
|
||||
// Clear returnToPrevious when the page is manually navigated to
|
||||
useEffect(() => {
|
||||
|
@ -90,10 +90,6 @@ export class AppChromeService {
|
||||
}
|
||||
|
||||
public setReturnToPrevious = (returnToPrevious: ReturnToPreviousProps) => {
|
||||
const isReturnToPreviousEnabled = config.featureToggles.returnToPrevious;
|
||||
if (!isReturnToPreviousEnabled) {
|
||||
return;
|
||||
}
|
||||
const previousPage = this.state.getValue().returnToPrevious;
|
||||
reportInteraction('grafana_return_to_previous_button_created', {
|
||||
page: returnToPrevious.href,
|
||||
@ -105,10 +101,6 @@ export class AppChromeService {
|
||||
};
|
||||
|
||||
public clearReturnToPrevious = (interactionAction: 'clicked' | 'dismissed' | 'auto_dismissed') => {
|
||||
const isReturnToPreviousEnabled = config.featureToggles.returnToPrevious;
|
||||
if (!isReturnToPreviousEnabled) {
|
||||
return;
|
||||
}
|
||||
const existingRtp = this.state.getValue().returnToPrevious;
|
||||
if (existingRtp) {
|
||||
reportInteraction('grafana_return_to_previous_button_dismissed', {
|
||||
|
@ -4,7 +4,7 @@ import React from 'react';
|
||||
import { TestProvider } from 'test/helpers/TestProvider';
|
||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { reportInteraction } from '@grafana/runtime';
|
||||
|
||||
import { ReturnToPrevious, ReturnToPreviousProps } from './ReturnToPrevious';
|
||||
|
||||
@ -36,14 +36,9 @@ const setup = () => {
|
||||
};
|
||||
|
||||
describe('ReturnToPrevious', () => {
|
||||
beforeEach(() => {
|
||||
/* We enabled the feature toggle */
|
||||
config.featureToggles.returnToPrevious = true;
|
||||
});
|
||||
afterEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
jest.resetAllMocks();
|
||||
config.featureToggles.returnToPrevious = false;
|
||||
});
|
||||
it('should render component', async () => {
|
||||
setup();
|
||||
|
@ -40,6 +40,8 @@ export function getNavTitle(navId: string | undefined) {
|
||||
return t('nav.reporting.title', 'Reporting');
|
||||
case 'dashboards/public':
|
||||
return t('nav.public.title', 'Public dashboards');
|
||||
case 'dashboards/trash':
|
||||
return t('nav.trash.title', 'Trash');
|
||||
case 'dashboards/new':
|
||||
return t('nav.new-dashboard.title', 'New dashboard');
|
||||
case 'dashboards/folder/new':
|
||||
@ -206,6 +208,11 @@ export function getNavSubTitle(navId: string | undefined) {
|
||||
);
|
||||
case 'dashboards/library-panels':
|
||||
return t('nav.library-panels.subtitle', 'Reusable panels that can be added to multiple dashboards');
|
||||
case 'dashboards/trash':
|
||||
return t(
|
||||
'nav.trash.subtitle',
|
||||
'Any items remaining in the Trash for more than 30 days will be automatically deleted'
|
||||
);
|
||||
case 'alerting':
|
||||
return t('nav.alerting.subtitle', 'Learn about problems in your systems moments after they occur');
|
||||
case 'alerting-upgrade':
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { Fragment } from 'react';
|
||||
|
||||
import { textUtil } from '@grafana/data';
|
||||
import { config, useReturnToPrevious } from '@grafana/runtime';
|
||||
import { useReturnToPrevious } from '@grafana/runtime';
|
||||
import { Button, LinkButton, Stack } from '@grafana/ui';
|
||||
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
|
||||
|
||||
@ -68,7 +68,6 @@ const RuleDetailsButtons = ({ rule, rulesSource }: Props) => {
|
||||
}
|
||||
if (rule.annotations[Annotation.dashboardUID]) {
|
||||
const dashboardUID = rule.annotations[Annotation.dashboardUID];
|
||||
const isReturnToPreviousEnabled = config.featureToggles.returnToPrevious;
|
||||
if (dashboardUID) {
|
||||
buttons.push(
|
||||
<LinkButton
|
||||
@ -76,7 +75,6 @@ const RuleDetailsButtons = ({ rule, rulesSource }: Props) => {
|
||||
key="dashboard"
|
||||
variant="primary"
|
||||
icon="apps"
|
||||
target={isReturnToPreviousEnabled ? undefined : '_blank'}
|
||||
href={`d/${encodeURIComponent(dashboardUID)}`}
|
||||
onClick={() => {
|
||||
setReturnToPrevious(rule.name);
|
||||
@ -93,7 +91,6 @@ const RuleDetailsButtons = ({ rule, rulesSource }: Props) => {
|
||||
key="panel"
|
||||
variant="primary"
|
||||
icon="apps"
|
||||
target={isReturnToPreviousEnabled ? undefined : '_blank'}
|
||||
href={`d/${encodeURIComponent(dashboardUID)}?viewPanel=${encodeURIComponent(panelId)}`}
|
||||
onClick={() => {
|
||||
setReturnToPrevious(rule.name);
|
||||
|
@ -308,6 +308,7 @@ class UnThemedLogRow extends PureComponent<Props, State> {
|
||||
pinned={this.props.pinned}
|
||||
mouseIsOver={this.state.mouseIsOver}
|
||||
onBlur={this.onMouseLeave}
|
||||
expanded={this.state.showDetails}
|
||||
/>
|
||||
)}
|
||||
</tr>
|
||||
|
@ -160,4 +160,41 @@ describe('LogRowMessage', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('For multi-line logs', () => {
|
||||
const entry = `Line1
|
||||
line2
|
||||
line3`;
|
||||
const singleLineEntry = entry.replace(/(\r\n|\n|\r)/g, '');
|
||||
it('Displays the original log line when wrapping is enabled', () => {
|
||||
setup({
|
||||
row: createLogRow({ entry, logLevel: LogLevel.error, timeEpochMs: 1546297200000 }),
|
||||
wrapLogMessage: true,
|
||||
});
|
||||
expect(screen.getByText(/Line1/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/line2/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/line3/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(singleLineEntry)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Removes new lines from the original log line when wrapping is disabled', () => {
|
||||
setup({
|
||||
row: createLogRow({ entry, logLevel: LogLevel.error, timeEpochMs: 1546297200000 }),
|
||||
wrapLogMessage: false,
|
||||
});
|
||||
expect(screen.getByText(singleLineEntry)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Displays the original log line when the line is expanded', () => {
|
||||
setup({
|
||||
row: createLogRow({ entry, logLevel: LogLevel.error, timeEpochMs: 1546297200000 }),
|
||||
wrapLogMessage: true,
|
||||
expanded: true,
|
||||
});
|
||||
expect(screen.getByText(/Line1/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/line2/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/line3/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(singleLineEntry)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -29,6 +29,7 @@ interface Props {
|
||||
styles: LogRowStyles;
|
||||
mouseIsOver: boolean;
|
||||
onBlur: () => void;
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
interface LogMessageProps {
|
||||
@ -58,13 +59,20 @@ const LogMessage = ({ hasAnsi, entry, highlights, styles }: LogMessageProps) =>
|
||||
return <>{entry}</>;
|
||||
};
|
||||
|
||||
const restructureLog = (line: string, prettifyLogMessage: boolean): string => {
|
||||
const restructureLog = (
|
||||
line: string,
|
||||
prettifyLogMessage: boolean,
|
||||
wrapLogMessage: boolean,
|
||||
expanded: boolean
|
||||
): string => {
|
||||
if (prettifyLogMessage) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(line), undefined, 2);
|
||||
} catch (error) {
|
||||
return line;
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
// With wrapping disabled, we want to turn it into a single-line log entry unless the line is expanded
|
||||
if (!wrapLogMessage && !expanded) {
|
||||
line = line.replace(/(\r\n|\n|\r)/g, '');
|
||||
}
|
||||
return line;
|
||||
};
|
||||
@ -84,9 +92,13 @@ export const LogRowMessage = React.memo((props: Props) => {
|
||||
mouseIsOver,
|
||||
onBlur,
|
||||
getRowContextQuery,
|
||||
expanded,
|
||||
} = props;
|
||||
const { hasAnsi, raw } = row;
|
||||
const restructuredEntry = useMemo(() => restructureLog(raw, prettifyLogMessage), [raw, prettifyLogMessage]);
|
||||
const restructuredEntry = useMemo(
|
||||
() => restructureLog(raw, prettifyLogMessage, wrapLogMessage, Boolean(expanded)),
|
||||
[raw, prettifyLogMessage, wrapLogMessage, expanded]
|
||||
);
|
||||
const shouldShowMenu = useMemo(() => mouseIsOver || pinned, [mouseIsOver, pinned]);
|
||||
return (
|
||||
<>
|
||||
|
15
public/app/features/trash-section/TrashPage.tsx
Normal file
15
public/app/features/trash-section/TrashPage.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
const TrashPage = () => {
|
||||
return (
|
||||
<Page navId="dashboards/trash">
|
||||
<Page.Contents>
|
||||
<p>page content</p>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrashPage;
|
@ -435,6 +435,13 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
() => import(/* webpackChunkName: "SnapshotListPage" */ 'app/features/manage-dashboards/SnapshotListPage')
|
||||
),
|
||||
},
|
||||
config.featureToggles.dashboardRestore && {
|
||||
path: '/dashboard/trash',
|
||||
roles: () => contextSrv.evaluatePermission([AccessControlAction.DashboardsDelete]),
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "TrashPage" */ 'app/features/trash-section/TrashPage')
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/playlists',
|
||||
component: SafeDynamicImport(
|
||||
|
@ -1179,6 +1179,10 @@
|
||||
"subtitle": "Optimieren Sie die Leistung mit Erkenntnissen aus k6 und Synthetic Monitoring",
|
||||
"title": "Testen & Synthetics"
|
||||
},
|
||||
"trash": {
|
||||
"subtitle": "",
|
||||
"title": ""
|
||||
},
|
||||
"upgrading": {
|
||||
"title": "Statistiken und Lizenz"
|
||||
},
|
||||
|
@ -1179,6 +1179,10 @@
|
||||
"subtitle": "Optimize performance with k6 and Synthetic Monitoring insights",
|
||||
"title": "Testing & synthetics"
|
||||
},
|
||||
"trash": {
|
||||
"subtitle": "Any items remaining in the Trash for more than 30 days will be automatically deleted",
|
||||
"title": "Trash"
|
||||
},
|
||||
"upgrading": {
|
||||
"title": "Stats and license"
|
||||
},
|
||||
|
@ -1179,6 +1179,10 @@
|
||||
"subtitle": "Optimiza el rendimiento con información de seguimiento k6 y sintética",
|
||||
"title": "Pruebas y síntesis"
|
||||
},
|
||||
"trash": {
|
||||
"subtitle": "",
|
||||
"title": ""
|
||||
},
|
||||
"upgrading": {
|
||||
"title": "Estadísticas y licencia"
|
||||
},
|
||||
|
@ -1179,6 +1179,10 @@
|
||||
"subtitle": "Optimisez les performances grâce aux analyses de la surveillance synthétique et k6",
|
||||
"title": "Tests et synthèses"
|
||||
},
|
||||
"trash": {
|
||||
"subtitle": "",
|
||||
"title": ""
|
||||
},
|
||||
"upgrading": {
|
||||
"title": "Statistiques et licence"
|
||||
},
|
||||
|
@ -1179,6 +1179,10 @@
|
||||
"subtitle": "Øpŧįmįžę pęřƒőřmäʼnčę ŵįŧĥ ĸ6 äʼnđ Ŝyʼnŧĥęŧįč Mőʼnįŧőřįʼnģ įʼnşįģĥŧş",
|
||||
"title": "Ŧęşŧįʼnģ & şyʼnŧĥęŧįčş"
|
||||
},
|
||||
"trash": {
|
||||
"subtitle": "Åʼny įŧęmş řęmäįʼnįʼnģ įʼn ŧĥę Ŧřäşĥ ƒőř mőřę ŧĥäʼn 30 đäyş ŵįľľ þę äūŧőmäŧįčäľľy đęľęŧęđ",
|
||||
"title": "Ŧřäşĥ"
|
||||
},
|
||||
"upgrading": {
|
||||
"title": "Ŝŧäŧş äʼnđ ľįčęʼnşę"
|
||||
},
|
||||
|
@ -1179,6 +1179,10 @@
|
||||
"subtitle": "Otimize o desempenho com visões k6 e monitorização sintética",
|
||||
"title": "Teste e sintética"
|
||||
},
|
||||
"trash": {
|
||||
"subtitle": "",
|
||||
"title": ""
|
||||
},
|
||||
"upgrading": {
|
||||
"title": "Estatísticas e licença"
|
||||
},
|
||||
|
@ -1173,6 +1173,10 @@
|
||||
"subtitle": "使用 k6 和 Synthetic Monitoring 洞察优化性能。",
|
||||
"title": "测试与合成"
|
||||
},
|
||||
"trash": {
|
||||
"subtitle": "",
|
||||
"title": ""
|
||||
},
|
||||
"upgrading": {
|
||||
"title": "统计信息和许可证"
|
||||
},
|
||||
|
@ -17143,6 +17143,42 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/datasources/uid/{uid}/lbac/teams": {
|
||||
"get": {
|
||||
"operationId": "getTeamLBACRulesApiResponse",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "uid",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/okResponse"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/badRequestError"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#/components/responses/unauthorisedError"
|
||||
},
|
||||
"403": {
|
||||
"$ref": "#/components/responses/forbiddenError"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#/components/responses/notFoundError"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/internalServerError"
|
||||
}
|
||||
},
|
||||
"summary": "Retrieves LBAC rules for a team."
|
||||
}
|
||||
},
|
||||
"/datasources/uid/{uid}/resources/{datasource_proxy_route}": {
|
||||
"get": {
|
||||
"operationId": "callDatasourceResourceWithUID",
|
||||
|
@ -101,7 +101,7 @@ $text-color: rgba(36, 41, 46, 1);
|
||||
$text-color-strong: #000000;
|
||||
$text-color-semi-weak: rgba(36, 41, 46, 0.75);
|
||||
$text-color-weak: rgba(36, 41, 46, 0.75);
|
||||
$text-color-faint: rgba(36, 41, 46, 0.50);
|
||||
$text-color-faint: rgba(36, 41, 46, 0.64);
|
||||
$text-color-emphasis: #000000;
|
||||
$text-blue: #1F62E0;
|
||||
|
||||
@ -114,7 +114,7 @@ $brand-gradient-vertical: linear-gradient(0.01deg, #F53E4C -31.2%, #FF8833 113.0
|
||||
// Links
|
||||
// -------------------------
|
||||
$link-color: rgba(36, 41, 46, 1);
|
||||
$link-color-disabled: rgba(36, 41, 46, 0.50);
|
||||
$link-color-disabled: rgba(36, 41, 46, 0.64);
|
||||
$link-hover-color: #000000;
|
||||
$external-link-color: #1F62E0;
|
||||
|
||||
@ -215,7 +215,7 @@ $input-border-color: rgba(36, 41, 46, 0.30);
|
||||
$input-box-shadow: none;
|
||||
$input-border-focus: #5794f2;
|
||||
$input-box-shadow-focus: #5794f2;
|
||||
$input-color-placeholder: rgba(36, 41, 46, 0.50);
|
||||
$input-color-placeholder: rgba(36, 41, 46, 0.64);
|
||||
$input-label-bg: #F4F5F5;
|
||||
$input-color-select-arrow: #7b8087;
|
||||
|
||||
|
52
yarn.lock
52
yarn.lock
@ -3648,6 +3648,7 @@ __metadata:
|
||||
"@testing-library/jest-dom": "npm:6.4.2"
|
||||
"@testing-library/react": "npm:15.0.2"
|
||||
"@testing-library/user-event": "npm:14.5.2"
|
||||
"@types/chance": "npm:1.1.6"
|
||||
"@types/common-tags": "npm:^1.8.0"
|
||||
"@types/d3": "npm:7.4.3"
|
||||
"@types/hoist-non-react-statics": "npm:3.3.5"
|
||||
@ -3676,6 +3677,7 @@ __metadata:
|
||||
"@types/uuid": "npm:9.0.8"
|
||||
ansicolor: "npm:1.1.100"
|
||||
calculate-size: "npm:1.1.1"
|
||||
chance: "npm:1.1.11"
|
||||
classnames: "npm:2.5.1"
|
||||
common-tags: "npm:1.8.2"
|
||||
core-js: "npm:3.37.0"
|
||||
@ -7827,7 +7829,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/chance@npm:^1.1.3":
|
||||
"@types/chance@npm:1.1.6, @types/chance@npm:^1.1.3":
|
||||
version: 1.1.6
|
||||
resolution: "@types/chance@npm:1.1.6"
|
||||
checksum: 10/f4366f1b3144d143af3e6f0fad2ed1db7b9bdfa7d82d40944e9619d57fe7e6b60e8c1452f47a8ededa6b2188932879518628ecd9aac81c40384ded39c26338ba
|
||||
@ -11639,7 +11641,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"chance@npm:^1.0.10":
|
||||
"chance@npm:1.1.11, chance@npm:^1.0.10":
|
||||
version: 1.1.11
|
||||
resolution: "chance@npm:1.1.11"
|
||||
checksum: 10/d76cc76dbcb837f051e6080d94be8bdcd07559d52077854f614c7081062fee10d4c3b508f6ae4b70303dac000422ea35160b01de165fcd176a01f67ea4b2cef5
|
||||
@ -16943,7 +16945,7 @@ __metadata:
|
||||
react-dom: "npm:18.2.0"
|
||||
react-draggable: "npm:4.4.6"
|
||||
react-dropzone: "npm:^14.2.3"
|
||||
react-grid-layout: "npm:1.4.4"
|
||||
react-grid-layout: "patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch"
|
||||
react-highlight-words: "npm:0.20.0"
|
||||
react-hook-form: "npm:^7.49.2"
|
||||
react-i18next: "npm:^14.0.0"
|
||||
@ -20251,13 +20253,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.isequal@npm:^4.0.0":
|
||||
version: 4.5.0
|
||||
resolution: "lodash.isequal@npm:4.5.0"
|
||||
checksum: 10/82fc58a83a1555f8df34ca9a2cd300995ff94018ac12cc47c349655f0ae1d4d92ba346db4c19bbfc90510764e0c00ddcc985a358bdcd4b3b965abf8f2a48a214
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lodash.ismatch@npm:^4.4.0":
|
||||
version: 4.4.0
|
||||
resolution: "lodash.ismatch@npm:4.4.0"
|
||||
@ -24784,7 +24779,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-draggable@npm:4.4.6, react-draggable@npm:^4.0.0, react-draggable@npm:^4.0.3, react-draggable@npm:^4.4.5":
|
||||
"react-draggable@npm:4.4.6, react-draggable@npm:^4.0.3, react-draggable@npm:^4.4.5":
|
||||
version: 4.4.6
|
||||
resolution: "react-draggable@npm:4.4.6"
|
||||
dependencies:
|
||||
@ -24860,22 +24855,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-grid-layout@npm:1.3.4":
|
||||
version: 1.3.4
|
||||
resolution: "react-grid-layout@npm:1.3.4"
|
||||
dependencies:
|
||||
clsx: "npm:^1.1.1"
|
||||
lodash.isequal: "npm:^4.0.0"
|
||||
prop-types: "npm:^15.8.1"
|
||||
react-draggable: "npm:^4.0.0"
|
||||
react-resizable: "npm:^3.0.4"
|
||||
peerDependencies:
|
||||
react: ">= 16.3.0"
|
||||
react-dom: ">= 16.3.0"
|
||||
checksum: 10/944ab133e59bfaa5633625f57be9f69133b5cec2de0232d9581e2c988e257ebafe010ee9bbbff6c2754f9d7d8bb0053072bac103f20fc232be2a58e15d14fc64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-grid-layout@npm:1.4.4":
|
||||
version: 1.4.4
|
||||
resolution: "react-grid-layout@npm:1.4.4"
|
||||
@ -24893,6 +24872,23 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-grid-layout@patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch":
|
||||
version: 1.4.4
|
||||
resolution: "react-grid-layout@patch:react-grid-layout@npm%3A1.4.4#~/.yarn/patches/react-grid-layout-npm-1.4.4-4024c5395b.patch::version=1.4.4&hash=e69ce6"
|
||||
dependencies:
|
||||
clsx: "npm:^2.0.0"
|
||||
fast-equals: "npm:^4.0.3"
|
||||
prop-types: "npm:^15.8.1"
|
||||
react-draggable: "npm:^4.4.5"
|
||||
react-resizable: "npm:^3.0.5"
|
||||
resize-observer-polyfill: "npm:^1.5.1"
|
||||
peerDependencies:
|
||||
react: ">= 16.3.0"
|
||||
react-dom: ">= 16.3.0"
|
||||
checksum: 10/fcc4fe40e38d723870dbc138af9a4a58dff6459efd191d03a66de2e663e169591a66d77b0f377bad780f64f4e24ca41735e4b2ecd99ae7c48071a0b8a1876a25
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-highlight-words@npm:0.20.0":
|
||||
version: 0.20.0
|
||||
resolution: "react-highlight-words@npm:0.20.0"
|
||||
@ -25117,7 +25113,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-resizable@npm:3.0.5, react-resizable@npm:^3.0.4, react-resizable@npm:^3.0.5":
|
||||
"react-resizable@npm:3.0.5, react-resizable@npm:^3.0.5":
|
||||
version: 3.0.5
|
||||
resolution: "react-resizable@npm:3.0.5"
|
||||
dependencies:
|
||||
|
Loading…
Reference in New Issue
Block a user