Merge remote-tracking branch 'origin/main' into drclau/unistor/replace-authenticators-3

drclau/unistor/namespace_authorizer
gamab 9 months ago
commit a1b6408127
No known key found for this signature in database
GPG Key ID: 88D8810B587562C1
  1. 14
      .betterer.results
  2. 1
      .github/CODEOWNERS
  3. 2
      docs/sources/alerting/alerting-rules/create-grafana-managed-rule.md
  4. 10
      docs/sources/alerting/set-up/configure-high-availability/_index.md
  5. 1
      packages/grafana-data/src/types/trace.ts
  6. 2
      packages/grafana-ui/src/components/Combobox/Combobox.test.tsx
  7. 2
      pkg/services/accesscontrol/resourcepermissions/store.go
  8. 8
      pkg/tsdb/tempo/trace_transform.go
  9. 26
      pkg/tsdb/tempo/trace_transform_test.go
  10. 2
      public/app/app.ts
  11. 8
      public/app/features/alerting/unified/Silences.test.tsx
  12. 4
      public/app/features/alerting/unified/api/alertmanagerApi.ts
  13. 7
      public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx
  14. 2
      public/app/features/alerting/unified/components/silences/SilencedInstancesPreview.tsx
  15. 31
      public/app/features/alerting/unified/mocks/server/handlers/alertmanagers.ts
  16. 2
      public/app/features/all.ts
  17. 2
      public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataAlertingTab.test.tsx
  18. 38
      public/app/features/dashboard-scene/panel-edit/PanelVizTypePicker.tsx
  19. 2
      public/app/features/dashboard-scene/scene/DashboardScene.test.tsx
  20. 4
      public/app/features/dashboard-scene/scene/DashboardScene.tsx
  21. 97
      public/app/features/dashboard-scene/settings/DeleteDashboardButton.tsx
  22. 27
      public/app/features/dashboard/components/DeleteDashboard/DeleteDashboardModal.tsx
  23. 7
      public/app/features/dashboard/index.ts
  24. 20
      public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianKeyValues.tsx
  25. 44
      public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.test.tsx
  26. 46
      public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianLogs.tsx
  27. 3
      public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/KeyValuesTable.tsx
  28. 1
      public/app/features/explore/TraceView/components/types/trace.ts
  29. 5
      public/app/features/explore/TraceView/components/utils/filter-spans.test.ts
  30. 3
      public/app/features/explore/TraceView/components/utils/filter-spans.tsx
  31. 44
      public/app/features/manage-dashboards/state/actions.ts
  32. 1
      public/app/features/plugins/all.ts
  33. 1
      public/app/plugins/datasource/jaeger/_importedDependencies/types/trace.ts
  34. 1
      public/app/plugins/datasource/jaeger/types.ts
  35. 4
      public/app/plugins/datasource/tempo/resultTransformer.ts
  36. 25
      public/app/plugins/datasource/tempo/test/testResponse.ts
  37. 5
      public/locales/en-US/grafana.json
  38. 5
      public/locales/pseudo-LOCALE/grafana.json
  39. 6
      yarn.lock

@ -2886,8 +2886,7 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"]
],
"public/app/features/dashboard-scene/settings/JsonModelEditView.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"],
@ -4097,7 +4096,9 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "4"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "5"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "6"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"]
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "7"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "8"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "9"]
],
"public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanDetail/AccordianReferences.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
@ -4679,12 +4680,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
[0, 0, 0, "Unexpected any. Specify a different type.", "9"],
[0, 0, 0, "Unexpected any. Specify a different type.", "10"],
[0, 0, 0, "Unexpected any. Specify a different type.", "11"],
[0, 0, 0, "Unexpected any. Specify a different type.", "12"],
[0, 0, 0, "Unexpected any. Specify a different type.", "13"]
[0, 0, 0, "Unexpected any. Specify a different type.", "8"]
],
"public/app/features/manage-dashboards/state/reducers.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],

@ -403,7 +403,6 @@ playwright.config.ts @grafana/plugins-platform-frontend
/public/app/core/components/TimelineChart/ @grafana/dataviz-squad
/public/app/core/components/Form/ @grafana/grafana-frontend-platform
/public/app/core/history/ @grafana/explore-squad
/public/app/features/all.ts @grafana/grafana-frontend-platform
/public/app/features/admin/ @grafana/identity-access-team
# Temp owners until Enterprise team takes over

@ -83,7 +83,7 @@ Grafana-managed rules are the most flexible alert rule type. They allow you to c
Multiple alert instances can be created as a result of one alert rule (also known as a multi-dimensional alerting).
{{% admonition type="note" %}}
For Grafana Cloud, you can create 100 free Grafana-managed alert rules.
For Grafana Cloud Free Forever, you can create up to 100 free Grafana-managed alert rules with each alert rule having a maximum of 1000 alert instances.
{{% /admonition %}}
Grafana managed alert rules can only be edited or deleted by users with Edit permissions for the folder storing the rules.

@ -190,3 +190,13 @@ Note that these alerting high availability metrics are exposed via the `/metrics
```
For more information on monitoring alerting metrics, refer to [Alerting meta-monitoring](ref:meta-monitoring). For a demo, see [alerting high availability examples using Docker Compose](https://github.com/grafana/alerting-ha-docker-examples/).
## Prevent duplicate notifications
In high-availability mode, each Grafana instance runs its own pre-configured alertmanager to handle alert notifications.
When multiple Grafana instances are running, all alert rules are evaluated on each instance. By default, each instance sends firing alerts to its respective alertmanager. This results in notification handling being duplicated across all running Grafana instances.
Alertmanagers in HA mode communicate with each other to coordinate notification delivery. However, this setup can sometimes lead to duplicated or out-of-order notifications. By design, HA prioritizes sending duplicate notifications over the risk of missing notifications.
To avoid duplicate notifications, you can configure a shared alertmanager to manage notifications for all Grafana instances. For more information, refer to [add an external alertmanager](/docs/grafana/<GRAFANA_VERSION>/alerting/set-up/configure-alertmanager/).

@ -13,6 +13,7 @@ export type TraceLog = {
// Millisecond epoch time
timestamp: number;
fields: TraceKeyValuePair[];
name?: string;
};
export type TraceSpanReference = {

@ -37,7 +37,7 @@ describe('Combobox', () => {
render(<Combobox options={options} onChange={onChangeHandler} value={null} />);
const input = screen.getByRole('combobox');
userEvent.click(input);
await userEvent.click(input);
const item = await screen.findByRole('option', { name: 'Option 1' });
await userEvent.click(item);

@ -725,7 +725,7 @@ func (s *store) createPermissions(sess *db.Session, roleID int64, cmd SetResourc
}
func (s *store) shouldStoreActionSet(resource, permission string) bool {
if !(s.features.IsEnabled(context.TODO(), featuremgmt.FlagAccessActionSets) && permission != "") {
if permission == "" {
return false
}
actionSetName := GetActionSetName(resource, permission)

@ -22,6 +22,7 @@ type TraceLog struct {
// Millisecond epoch time
Timestamp float64 `json:"timestamp"`
Fields []*KeyValue `json:"fields"`
Name string `json:"name,omitempty"`
}
type TraceReference struct {
@ -260,12 +261,6 @@ func spanEventsToLogs(events ptrace.SpanEventSlice) []*TraceLog {
for i := 0; i < events.Len(); i++ {
event := events.At(i)
fields := make([]*KeyValue, 0, event.Attributes().Len()+1)
if event.Name() != "" {
fields = append(fields, &KeyValue{
Key: TagMessage,
Value: event.Name(),
})
}
event.Attributes().Range(func(key string, attr pcommon.Value) bool {
fields = append(fields, &KeyValue{Key: key, Value: getAttributeVal(attr)})
return true
@ -273,6 +268,7 @@ func spanEventsToLogs(events ptrace.SpanEventSlice) []*TraceLog {
logs = append(logs, &TraceLog{
Timestamp: float64(event.Timestamp()) / 1_000_000,
Fields: fields,
Name: event.Name(),
})
}

@ -9,6 +9,7 @@ import (
"go.opentelemetry.io/collector/pdata/ptrace"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -57,7 +58,30 @@ func TestTraceToFrame(t *testing.T) {
require.Equal(t, json.RawMessage("[{\"value\":\"loki-all\",\"key\":\"service.name\"},{\"value\":\"Jaeger-Go-2.25.0\",\"key\":\"opencensus.exporterversion\"},{\"value\":\"4d019a031941\",\"key\":\"host.hostname\"},{\"value\":\"172.18.0.6\",\"key\":\"ip\"},{\"value\":\"4b19ace06df8e4de\",\"key\":\"client-uuid\"}]"), span["serviceTags"])
require.Equal(t, 1616072924072.852, span["startTime"])
require.Equal(t, 0.094, span["duration"])
require.Equal(t, "[{\"timestamp\":1616072924072.856,\"fields\":[{\"value\":\"test event\",\"key\":\"message\"},{\"value\":1,\"key\":\"chunks requested\"}]},{\"timestamp\":1616072924072.9448,\"fields\":[{\"value\":1,\"key\":\"chunks fetched\"}]}]", string(span["logs"].(json.RawMessage)))
expectedLogs := `
[
{
"timestamp": 1616072924072.856,
"name": "test event",
"fields": [
{
"value": 1,
"key": "chunks requested"
}
]
},
{
"timestamp": 1616072924072.9448,
"fields": [
{
"value": 1,
"key": "chunks fetched"
}
]
}
]
`
assert.JSONEq(t, expectedLogs, string(span["logs"].(json.RawMessage)))
})
t.Run("should transform correct traceID", func(t *testing.T) {

@ -6,8 +6,6 @@ import 'file-saver';
import 'jquery';
import 'vendor/bootstrap/bootstrap';
import 'app/features/all';
import _ from 'lodash'; // eslint-disable-line lodash/import-scope
import { createElement } from 'react';
import { createRoot } from 'react-dom/client';

@ -304,6 +304,14 @@ describe('Silence create/edit', () => {
TEST_TIMEOUT
);
it('works when previewing alerts with spaces in label name', async () => {
renderSilences(`${baseUrlPath}?alertmanager=${GRAFANA_RULES_SOURCE_NAME}`);
await enterSilenceLabel(0, 'label with spaces', MatcherOperator.equal, 'value with spaces');
expect((await screen.findAllByTestId('row'))[0]).toBeInTheDocument();
});
it('shows an error when existing silence cannot be found', async () => {
renderSilences('/alerting/silence/foo-bar/edit');

@ -72,7 +72,9 @@ export const alertmanagerApi = alertingApi.injectEndpoints({
// TODO Add support for active, silenced, inhibited, unprocessed filters
const filterMatchers = filter?.matchers
?.filter((matcher) => matcher.name && matcher.value)
.map((matcher) => `${matcher.name}${matcherToOperator(matcher)}${wrapWithQuotes(matcher.value)}`);
.map(
(matcher) => `${wrapWithQuotes(matcher.name)}${matcherToOperator(matcher)}${wrapWithQuotes(matcher.value)}`
);
const { silenced, inhibited, unprocessed, active } = filter || {};

@ -14,6 +14,7 @@ import InfoPausedRule from 'app/features/alerting/unified/components/InfoPausedR
import {
getRuleGroupLocationFromFormValues,
getRuleGroupLocationFromRuleWithLocation,
isCloudRulerRule,
isGrafanaManagedRuleByType,
isGrafanaRulerRule,
isGrafanaRulerRulePaused,
@ -42,7 +43,7 @@ import {
formValuesToRulerGrafanaRuleDTO,
formValuesToRulerRuleDTO,
} from '../../../utils/rule-form';
import { fromRulerRuleAndRuleGroupIdentifier } from '../../../utils/rule-id';
import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier, stringifyIdentifier } from '../../../utils/rule-id';
import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter';
import { AlertRuleNameAndMetric } from '../AlertRuleNameInput';
import AnnotationsStep from '../AnnotationsStep';
@ -167,6 +168,10 @@ export const AlertRuleForm = ({ existing, prefill }: Props) => {
if (exitOnSave && returnTo) {
locationService.push(returnTo);
} else if (isCloudRulerRule(ruleDefinition)) {
const { dataSourceName, namespaceName, groupName } = getRuleGroupLocationFromFormValues(values);
const updatedRuleIdentifier = fromRulerRule(dataSourceName, namespaceName, groupName, ruleDefinition);
locationService.replace(`/alerting/${encodeURIComponent(stringifyIdentifier(updatedRuleIdentifier))}/edit`);
}
};

@ -66,7 +66,7 @@ export const SilencedInstancesPreview = ({ amSourceName, matchers: inputMatchers
if (isError) {
return (
<Alert title="Preview not available" severity="error">
Error occured when generating preview of affected alerts. Are your matchers valid?
Error occurred when generating preview of affected alerts. Are your matchers valid?
</Alert>
);
}

@ -12,8 +12,37 @@ export const grafanaAlertingConfigurationStatusHandler = (
response = defaultGrafanaAlertingConfigurationStatusResponse
) => http.get('/api/v1/ngalert', () => HttpResponse.json(response));
const getInvalidMatcher = (matchers: string[]) => {
return matchers.find((matcher) => {
const split = matcher.split('=');
try {
// Try and parse as JSON, as this will fail if
// we've failed to wrap the label value in quotes
// (e.g. `foo space` can't be parsed, but `"foo space"` can)
JSON.parse(split[0]);
return false;
} catch (e) {
return true;
}
});
};
export const alertmanagerAlertsListHandler = () =>
http.get<{ datasourceUid: string }>('/api/alertmanager/:datasourceUid/api/v2/alerts', ({ params }) => {
http.get<{ datasourceUid: string }>('/api/alertmanager/:datasourceUid/api/v2/alerts', ({ params, request }) => {
const matchers = new URL(request.url).searchParams.getAll('filter');
const invalidMatcher = getInvalidMatcher(matchers);
if (invalidMatcher) {
return HttpResponse.json(
{
message: `bad matcher format: ${invalidMatcher}: unable to retrieve alerts`,
traceID: '',
},
{ status: 400 }
);
}
if (params.datasourceUid === MOCK_DATASOURCE_UID_BROKEN_ALERTMANAGER) {
return HttpResponse.json({ traceId: '' }, { status: 502 });
}

@ -1,2 +0,0 @@
import './plugins/all';
import './dashboard';

@ -143,7 +143,7 @@ const dashboard = {
from: 'now-6h',
to: 'now',
},
timepicker: { refresh_intervals: 5 },
timepicker: { refresh_intervals: ['5s', '30s', '1m'] },
meta: {
canSave: true,
folderId: 1,

@ -3,7 +3,8 @@ import { useEffect, useMemo, useState } from 'react';
import { useLocalStorage } from 'react-use';
import { GrafanaTheme2, PanelData, SelectableValue } from '@grafana/data';
import { CustomScrollbar, Field, FilterInput, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { Button, CustomScrollbar, Field, FilterInput, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { LS_VISUALIZATION_SELECT_TAB_KEY, LS_WIDGET_SELECT_TAB_KEY } from 'app/core/constants';
import { VisualizationSelectPaneTab } from 'app/features/dashboard/components/PanelEditor/types';
import { VisualizationSuggestions } from 'app/features/panel/components/VizTypePicker/VisualizationSuggestions';
@ -61,15 +62,29 @@ export function PanelVizTypePicker({ vizManager, data, onChange }: Props) {
onChange();
};
const onCloseVizPicker = () => {
onChange();
};
return (
<div className={styles.wrapper}>
<FilterInput
className={styles.filter}
value={searchQuery}
onChange={setSearchQuery}
autoFocus={true}
placeholder="Search for..."
/>
<div className={styles.searchRow}>
<FilterInput
className={styles.filter}
value={searchQuery}
onChange={setSearchQuery}
autoFocus={true}
placeholder="Search for..."
/>
<Button
title="Close"
variant="secondary"
icon="angle-up"
className={styles.closeButton}
data-testid={selectors.components.PanelEditor.toggleVizPicker}
onClick={onCloseVizPicker}
/>
</div>
<Field className={styles.customFieldMargin}>
<RadioButtonGroup options={radioOptions} value={listMode} onChange={setListMode} fullWidth />
</Field>
@ -106,6 +121,13 @@ const getStyles = (theme: GrafanaTheme2) => ({
borderBottom: 'none',
borderTopLeftRadius: theme.shape.radius.default,
}),
searchRow: css({
display: 'flex',
marginBottom: theme.spacing(1),
}),
closeButton: css({
marginLeft: theme.spacing(1),
}),
customFieldMargin: css({
marginBottom: theme.spacing(1),
}),

@ -1000,7 +1000,7 @@ describe('DashboardScene', () => {
scene.setState({ isDirty: true });
locationService.push('/d/adsdas');
await scene.deleteDashboard();
await scene.onDashboardDelete();
expect(scene.state.isDirty).toBe(false);
});

@ -34,7 +34,6 @@ import store from 'app/core/store';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { deleteDashboard } from 'app/features/manage-dashboards/state/actions';
import { getClosestScopesFacade, ScopesFacade } from 'app/features/scopes';
import { VariablesChanged } from 'app/features/variables/types';
import { DashboardDTO, DashboardMeta, KioskMode, SaveDashboardResponseDTO } from 'app/types';
@ -891,8 +890,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
this._initialSaveModel = saveModel;
}
public async deleteDashboard() {
await deleteDashboard(this.state.uid!, true);
public async onDashboardDelete() {
// Need to mark it non dirty to navigate away without unsaved changes warning
this.setState({ isDirty: false });
locationService.replace('/');

@ -2,17 +2,56 @@ import { useAsyncFn, useToggle } from 'react-use';
import { selectors } from '@grafana/e2e-selectors';
import { config, reportInteraction } from '@grafana/runtime';
import { Button, ConfirmModal, Modal } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
import { Button, ConfirmModal, Modal, Space, Text } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { useDeleteItemsMutation } from '../../browse-dashboards/api/browseDashboardsAPI';
import { DashboardScene } from '../scene/DashboardScene';
interface ButtonProps {
dashboard: DashboardScene;
}
interface ProvisionedDeleteModalProps {
dashboardId: string | undefined;
onClose: () => void;
}
interface DeleteModalProps {
dashboardTitle: string;
onConfirm: () => void;
onClose: () => void;
}
export function DeleteDashboardButton({ dashboard }: ButtonProps) {
const [showModal, toggleModal] = useToggle(false);
const [deleteItems] = useDeleteItemsMutation();
const [, onConfirm] = useAsyncFn(async () => {
reportInteraction('grafana_manage_dashboards_delete_clicked', {
item_counts: {
dashboard: 1,
},
source: 'dashboard_scene_settings',
restore_enabled: config.featureToggles.dashboardRestoreUI,
});
toggleModal();
if (dashboard.state.uid) {
await deleteItems({
selectedItems: {
dashboard: {
[dashboard.state.uid]: true,
},
folder: {},
},
});
}
await dashboard.onDashboardDelete();
}, [dashboard, toggleModal]);
if (dashboard.state.meta.provisioned && showModal) {
return <ProvisionedDeleteModal dashboardId={dashboard.state.meta.provisionedExternalId} onClose={toggleModal} />;
}
return (
<>
@ -24,52 +63,48 @@ export function DeleteDashboardButton({ dashboard }: ButtonProps) {
<Trans i18nKey="dashboard-settings.dashboard-delete-button">Delete dashboard</Trans>
</Button>
{showModal && <DeleteDashboardModal dashboard={dashboard} onClose={toggleModal} />}
{showModal && (
<DeleteDashboardModal dashboardTitle={dashboard.state.title} onConfirm={onConfirm} onClose={toggleModal} />
)}
</>
);
}
interface ModalProps {
dashboard: DashboardScene;
onClose: () => void;
}
function DeleteDashboardModal({ dashboard, onClose }: ModalProps) {
const [, onConfirm] = useAsyncFn(async () => {
reportInteraction('grafana_manage_dashboards_delete_clicked', {
item_counts: {
dashboard: 1,
},
source: 'dashboard_scene_settings',
restore_enabled: config.featureToggles.dashboardRestoreUI,
});
onClose();
await dashboard.deleteDashboard();
}, [dashboard, onClose]);
if (dashboard.state.meta.provisioned) {
return <ProvisionedDeleteModal dashboard={dashboard} onClose={onClose} />;
}
export function DeleteDashboardModal({ dashboardTitle, onConfirm, onClose }: DeleteModalProps) {
return (
<ConfirmModal
isOpen={true}
body={
<>
<p>Do you want to delete this dashboard?</p>
<p>{dashboard.state.title}</p>
{config.featureToggles.dashboardRestore && (
<>
<Text element="p">
<Trans i18nKey="dashboard-settings.delete-modal-restore-dashboards-text">
This action will mark the dashboard for deletion in 30 days. Your organization administrator can
restore it anytime before the 30 days expire.
</Trans>
</Text>
<Space v={1} />
</>
)}
<Text element="p">
<Trans i18nKey="dashboard-settings.delete-modal-text">Do you want to delete this dashboard?</Trans>
</Text>
<Text element="p">{dashboardTitle}</Text>
<Space v={2} />
</>
}
onConfirm={onConfirm}
onDismiss={onClose}
title="Delete"
title={t('dashboard-settings.delete-modal.title', 'Delete')}
icon="trash-alt"
confirmText="Delete"
confirmText={t('dashboard-settings.delete-modal.delete-button', 'Delete')}
confirmationText={t('dashboard-settings.delete-modal.confirmation-text', 'Delete')}
/>
);
}
function ProvisionedDeleteModal({ dashboard, onClose }: ModalProps) {
function ProvisionedDeleteModal({ dashboardId, onClose }: ProvisionedDeleteModalProps) {
return (
<Modal isOpen={true} title="Cannot delete provisioned dashboard" icon="trash-alt" onDismiss={onClose}>
<p>
@ -90,7 +125,7 @@ function ProvisionedDeleteModal({ dashboard, onClose }: ModalProps) {
for more information about provisioning.
</i>
<br />
File path: {dashboard.state.meta.provisionedExternalId}
File path: {dashboardId}
</p>
<Modal.ButtonRow>
<Button variant="primary" onClick={onClose}>

@ -3,12 +3,13 @@ import { connect, ConnectedProps } from 'react-redux';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import { locationService, config, reportInteraction } from '@grafana/runtime';
import { Modal, ConfirmModal, Button, Text, Space, TextLink } from '@grafana/ui';
import { Modal, Button, Text, Space, TextLink } from '@grafana/ui';
import { DashboardModel } from 'app/features/dashboard/state';
import { cleanUpDashboardAndVariables } from 'app/features/dashboard/state/actions';
import { Trans, t } from '../../../../core/internationalization';
import { useDeleteItemsMutation } from '../../../browse-dashboards/api/browseDashboardsAPI';
import { DeleteDashboardModal as DeleteModal } from '../../../dashboard-scene/settings/DeleteDashboardButton';
type DeleteDashboardModalProps = {
hideModal(): void;
@ -52,29 +53,7 @@ const DeleteDashboardModalUnconnected = ({ hideModal, cleanUpDashboardAndVariabl
return <ProvisionedDeleteModal hideModal={hideModal} provisionedId={dashboard.meta.provisionedExternalId!} />;
}
return (
<ConfirmModal
isOpen={true}
body={
<>
<Text element="p">
<Trans i18nKey="dashboard-settings.dashboard-delete-modal.text">
Do you want to delete this dashboard?
</Trans>
</Text>
<Space v={1} />
<Text element="p">{dashboard.title}</Text>
<Space v={2} />
</>
}
onConfirm={onConfirm}
onDismiss={hideModal}
title={t('dashboard-settings.dashboard-delete-modal.title', 'Delete')}
icon="trash-alt"
confirmText={t('dashboard-settings.dashboard-delete-modal.delete-button', 'Delete')}
confirmationText={t('dashboard-settings.dashboard-delete-modal.confirmation-text', 'Delete')}
/>
);
return <DeleteModal onConfirm={onConfirm} onClose={hideModal} dashboardTitle={dashboard.title} />;
};
const ProvisionedDeleteModal = ({ hideModal, provisionedId }: { hideModal(): void; provisionedId: string }) => (

@ -1,7 +0,0 @@
// Services
import './services/DashboardLoaderSrv';
import './services/DashboardSrv';
// Components
import './components/DashExportModal';
import './components/DashNav';
import './components/DashboardSettings';

@ -67,7 +67,6 @@ export const getStyles = (theme: GrafanaTheme2) => {
summaryItem: css`
label: summaryItem;
display: inline;
margin-left: 0.7em;
padding-right: 0.5rem;
border-right: 1px solid ${autoColor(theme, '#ddd')};
&:last-child {
@ -90,10 +89,11 @@ export const getStyles = (theme: GrafanaTheme2) => {
export type AccordianKeyValuesProps = {
className?: string | TNil;
data: TraceKeyValuePair[];
logName?: string;
highContrast?: boolean;
interactive?: boolean;
isOpen: boolean;
label: string;
label: string | React.ReactNode;
linksGetter?: ((pairs: TraceKeyValuePair[], index: number) => TraceLink[]) | TNil;
onToggle?: null | (() => void);
};
@ -127,6 +127,7 @@ export function KeyValuesSummary({ data = null }: KeyValuesSummaryProps) {
export default function AccordianKeyValues({
className = null,
data,
logName,
highContrast = false,
interactive = true,
isOpen,
@ -134,11 +135,12 @@ export default function AccordianKeyValues({
linksGetter,
onToggle = null,
}: AccordianKeyValuesProps) {
const isEmpty = !Array.isArray(data) || !data.length;
const isEmpty = (!Array.isArray(data) || !data.length) && !logName;
const styles = useStyles2(getStyles);
const iconCls = cx(alignIcon, { [styles.emptyIcon]: isEmpty });
let arrow: React.ReactNode | null = null;
let headerProps: {} | null = null;
const tableFields = logName ? [{ key: 'event name', value: logName }, ...data] : data;
if (interactive) {
arrow = isOpen ? (
<Icon name={'angle-down'} className={iconCls} />
@ -152,6 +154,8 @@ export default function AccordianKeyValues({
};
}
const showDataSummaryFields = data.length > 0 && !isOpen;
return (
<div className={cx(className, styles.container)}>
<div
@ -165,11 +169,15 @@ export default function AccordianKeyValues({
{arrow}
<strong data-test={markers.LABEL}>
{label}
{isOpen || ':'}
{showDataSummaryFields && ':'}
</strong>
{!isOpen && <KeyValuesSummary data={data} />}
{showDataSummaryFields && (
<span className={css({ marginLeft: '0.7em' })}>
<KeyValuesSummary data={data} />
</span>
)}
</div>
{isOpen && <KeyValuesTable data={data} linksGetter={linksGetter} />}
{isOpen && <KeyValuesTable data={tableFields} linksGetter={linksGetter} />}
</div>
);
}

@ -30,6 +30,7 @@ const logs = [
{ key: 'message', value: 'oh the next log message' },
{ key: 'more', value: 'stuff' },
],
name: 'foo event name',
},
];
@ -72,4 +73,47 @@ describe('AccordianLogs tests', () => {
expect(screen.getByText(/^something$/)).toBeInTheDocument();
expect(screen.getByText(/^else$/)).toBeInTheDocument();
});
it('shows log entries and long event name when expanded', () => {
const longNameLog = {
timestamp: 20,
name: 'This is a very very very very very very very long name',
fields: [{ key: 'foo', value: 'test' }],
};
setup({
isOpen: true,
logs: [longNameLog],
openedItems: new Set([longNameLog]),
} as AccordianLogsProps);
expect(
screen.getByRole('switch', {
name: '15μs (This is a very very ...)',
})
).toBeInTheDocument();
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.queryAllByRole('cell')).toHaveLength(6);
expect(screen.getByText(/^event name$/)).toBeInTheDocument();
expect(screen.getByText(/This is a very very very very very very very long name/)).toBeInTheDocument();
});
it('renders event name and duration when events list is closed', () => {
setup({ isOpen: true, openedItems: new Set() } as AccordianLogsProps);
expect(
screen.getByRole('switch', {
name: '15μs (foo event name) : message = oh the next log message more = stuff',
})
).toBeInTheDocument();
expect(
screen.getByRole('switch', { name: '5μs: message = oh the log message something = else' })
).toBeInTheDocument();
});
it('renders event name and duration when events list is open', () => {
setup({ isOpen: true, openedItems: new Set(logs) } as AccordianLogsProps);
expect(screen.getByRole('switch', { name: '15μs (foo event name)' })).toBeInTheDocument();
expect(screen.getByRole('switch', { name: '5μs' })).toBeInTheDocument();
});
});

@ -59,6 +59,9 @@ const getStyles = (theme: GrafanaTheme2) => {
AccordianKeyValuesItem: css({
marginBottom: theme.spacing(0.5),
}),
parenthesis: css({
color: `${autoColor(theme, '#777')}`,
}),
};
};
@ -108,22 +111,35 @@ export default function AccordianLogs({
</HeaderComponent>
{isOpen && (
<div className={styles.AccordianLogsContent}>
{_sortBy(logs, 'timestamp').map((log, i) => (
<AccordianKeyValues
// `i` is necessary in the key because timestamps can repeat
key={`${log.timestamp}-${i}`}
className={i < logs.length - 1 ? styles.AccordianKeyValuesItem : null}
data={log.fields || []}
highContrast
interactive={interactive}
isOpen={openedItems ? openedItems.has(log) : false}
label={`${formatDuration(log.timestamp - timestamp)}`}
linksGetter={linksGetter}
onToggle={interactive && onItemToggle ? () => onItemToggle(log) : null}
/>
))}
{_sortBy(logs, 'timestamp').map((log, i) => {
const formattedDuration = formatDuration(log.timestamp - timestamp);
const truncateLogNameInSummary = log.name && log.name.length > 20;
const formattedLogName = log.name && truncateLogNameInSummary ? log.name.slice(0, 20) + '...' : log.name;
const label = formattedLogName ? (
<span>
{formattedDuration} <span>({formattedLogName})</span>
</span>
) : (
formattedDuration
);
return (
<AccordianKeyValues
// `i` is necessary in the key because timestamps can repeat
key={`${log.timestamp}-${i}`}
className={i < logs.length - 1 ? styles.AccordianKeyValuesItem : null}
data={log.fields || []}
logName={truncateLogNameInSummary ? log.name : undefined}
highContrast
interactive={interactive}
isOpen={openedItems ? openedItems.has(log) : false}
label={label}
linksGetter={linksGetter}
onToggle={interactive && onItemToggle ? () => onItemToggle(log) : null}
/>
);
})}
<small className={styles.AccordianLogsFooter}>
Log timestamps are relative to the start time of the full trace.
Event timestamps are relative to the start time of the full trace.
</small>
</div>
)}

@ -48,7 +48,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
row: css`
label: row;
& > td {
padding: 0rem 0.5rem;
padding: 0.5rem 0.5rem;
height: 30px;
}
&:nth-child(2n) > td {
@ -63,6 +63,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
color: ${autoColor(theme, '#888')};
white-space: pre;
width: 125px;
vertical-align: top;
`,
copyColumn: css`
label: copyColumn;

@ -31,6 +31,7 @@ export type TraceLink = {
export type TraceLog = {
timestamp: number;
fields: TraceKeyValuePair[];
name?: string;
};
export type TraceProcess = {

@ -55,6 +55,7 @@ describe('filterSpans', () => {
],
logs: [
{
name: 'logName0',
fields: [
{
key: 'logFieldKey0',
@ -316,6 +317,10 @@ describe('filterSpans', () => {
).toEqual(new Set([spanID0]));
});
it('it should return logs have a name which matches the filter', () => {
expect(filterSpans({ ...defaultFilters, query: 'logName0' }, spans)).toEqual(new Set([spanID0]));
});
it('should return no spans when logs is null', () => {
const nullSpan = { ...span0, logs: null };
expect(

@ -90,7 +90,8 @@ export function getQueryMatches(query: string, spans: TraceSpan[] | TNil) {
(span.instrumentationLibraryName && isTextInQuery(queryParts, span.instrumentationLibraryName)) ||
(span.instrumentationLibraryVersion && isTextInQuery(queryParts, span.instrumentationLibraryVersion)) ||
(span.traceState && isTextInQuery(queryParts, span.traceState)) ||
(span.logs !== null && span.logs.some((log) => isTextInKeyValues(log.fields))) ||
(span.logs !== null &&
span.logs.some((log) => (log.name && isTextInQuery(queryParts, log.name)) || isTextInKeyValues(log.fields))) ||
isTextInKeyValues(span.process.tags) ||
queryParts.some((queryPart) => queryPart === span.spanID);

@ -3,7 +3,6 @@ import { getBackendSrv, getDataSourceSrv, isFetchError } from '@grafana/runtime'
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { browseDashboardsAPI, ImportInputs } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { FolderInfo, PermissionLevelString, SearchQueryType, ThunkResult } from 'app/types';
import {
@ -281,39 +280,6 @@ export async function moveFolders(folderUIDs: string[], toFolder: FolderInfo) {
return result;
}
function createTask(fn: (...args: any[]) => Promise<any>, ignoreRejections: boolean, ...args: any[]) {
return async (result: any) => {
try {
const res = await fn(...args);
return Array.prototype.concat(result, [res]);
} catch (err) {
if (ignoreRejections) {
return result;
}
throw err;
}
};
}
export function deleteFoldersAndDashboards(folderUids: string[], dashboardUids: string[]) {
const tasks = [];
for (const folderUid of folderUids) {
tasks.push(createTask(deleteFolder, true, folderUid, true));
}
for (const dashboardUid of dashboardUids) {
tasks.push(createTask(deleteDashboard, true, dashboardUid, true));
}
return executeInOrder(tasks);
}
function deleteFolder(uid: string, showSuccessAlert: boolean) {
return getBackendSrv().delete(`/api/folders/${uid}?forceDeleteRules=false`, undefined, { showSuccessAlert });
}
export function createFolder(payload: any) {
return getBackendSrv().post('/api/folders', payload);
}
@ -346,13 +312,3 @@ export function getFolderByUid(uid: string): Promise<{ uid: string; title: strin
export function getFolderById(id: number): Promise<{ id: number; title: string }> {
return getBackendSrv().get(`/api/folders/id/${id}`);
}
export function deleteDashboard(uid: string, showSuccessAlert: boolean) {
return getDashboardAPI().deleteDashboard(uid, showSuccessAlert);
}
function executeInOrder(tasks: any[]): Promise<unknown> {
return tasks.reduce((acc, task) => {
return Promise.resolve(acc).then(task);
}, []);
}

@ -1 +0,0 @@
import './datasource_srv';

@ -31,6 +31,7 @@ export type TraceLink = {
export type TraceLog = {
timestamp: number;
fields: TraceKeyValuePair[];
name?: string;
};
export type TraceProcess = {

@ -14,6 +14,7 @@ export type TraceLink = {
export type TraceLog = {
timestamp: number;
fields: TraceKeyValuePair[];
name?: string;
};
export type TraceProcess = {

@ -132,7 +132,7 @@ function getLogs(span: collectorTypes.opentelemetryProto.trace.v1.Span) {
fields.push({ key: attribute.key, value: getAttributeValue(attribute.value) });
}
}
logs.push({ fields, timestamp: event.timeUnixNano / 1000000 });
logs.push({ fields, timestamp: event.timeUnixNano / 1000000, name: event.name });
}
}
@ -364,7 +364,7 @@ function getOTLPEvents(logs: TraceLog[]): collectorTypes.opentelemetryProto.trac
timeUnixNano: log.timestamp * 1000000,
attributes: [],
droppedAttributesCount: 0,
name: '',
name: log.name || '',
};
for (const field of log.fields) {
event.attributes!.push({

@ -1920,7 +1920,7 @@ export const otlpDataFrameFromResponse = new MutableDataFrame({
name: 'logs',
type: FieldType.other,
config: {},
values: [[]],
values: [[{ name: 'DNSDone', fields: [{ key: 'addr', value: '172.18.0.6' }] }]],
},
{
name: 'references',
@ -2138,7 +2138,20 @@ export const otlpDataFrameToResponse = new MutableDataFrame({
name: 'logs',
type: FieldType.other,
config: {},
values: [[]],
values: [
[
{
fields: [
{
key: 'addr',
value: '172.18.0.6',
},
],
timestamp: 1627471657255.809,
name: 'DNSDone',
},
],
],
state: {
displayName: 'logs',
},
@ -2240,6 +2253,14 @@ export const otlpResponse = {
{ key: 'http.url', value: { stringValue: '/' } },
{ key: 'component', value: { stringValue: 'net/http' } },
],
events: [
{
name: 'DNSDone',
attributes: [{ key: 'addr', value: { stringValue: '172.18.0.6' } }],
droppedAttributesCount: 0,
timeUnixNano: 1627471657255809000,
},
],
links: [
{
spanId: 'spanId',

@ -607,12 +607,13 @@
"title": "Annotations"
},
"dashboard-delete-button": "Delete dashboard",
"dashboard-delete-modal": {
"delete-modal": {
"confirmation-text": "Delete",
"delete-button": "Delete",
"text": "Do you want to delete this dashboard?",
"title": "Delete"
},
"delete-modal-restore-dashboards-text": "This action will mark the dashboard for deletion in 30 days. Your organization administrator can restore it anytime before the 30 days expire.",
"delete-modal-text": "Do you want to delete this dashboard?",
"general": {
"auto-refresh-description": "Define the auto refresh intervals that should be available in the auto refresh list. Use the format '5s' for seconds, '1m' for minutes, '1h' for hours, and '1d' for days (e.g.: '5s,10s,30s,1m,5m,15m,30m,1h,2h,1d').",
"auto-refresh-label": "Auto refresh",

@ -607,12 +607,13 @@
"title": "Åʼnʼnőŧäŧįőʼnş"
},
"dashboard-delete-button": "Đęľęŧę đäşĥþőäřđ",
"dashboard-delete-modal": {
"delete-modal": {
"confirmation-text": "Đęľęŧę",
"delete-button": "Đęľęŧę",
"text": "Đő yőū ŵäʼnŧ ŧő đęľęŧę ŧĥįş đäşĥþőäřđ?",
"title": "Đęľęŧę"
},
"delete-modal-restore-dashboards-text": "Ŧĥįş äčŧįőʼn ŵįľľ mäřĸ ŧĥę đäşĥþőäřđ ƒőř đęľęŧįőʼn įʼn 30 đäyş. Ÿőūř őřģäʼnįžäŧįőʼn äđmįʼnįşŧřäŧőř čäʼn řęşŧőřę įŧ äʼnyŧįmę þęƒőřę ŧĥę 30 đäyş ęχpįřę.",
"delete-modal-text": "Đő yőū ŵäʼnŧ ŧő đęľęŧę ŧĥįş đäşĥþőäřđ?",
"general": {
"auto-refresh-description": "Đęƒįʼnę ŧĥę äūŧő řęƒřęşĥ įʼnŧęřväľş ŧĥäŧ şĥőūľđ þę äväįľäþľę įʼn ŧĥę äūŧő řęƒřęşĥ ľįşŧ. Ůşę ŧĥę ƒőřmäŧ '5ş' ƒőř şęčőʼnđş, '1m' ƒőř mįʼnūŧęş, '1ĥ' ƒőř ĥőūřş, äʼnđ '1đ' ƒőř đäyş (ę.ģ.: '5ş,10ş,30ş,1m,5m,15m,30m,1ĥ,2ĥ,1đ').",
"auto-refresh-label": "Åūŧő řęƒřęşĥ",

@ -3933,8 +3933,8 @@ __metadata:
linkType: soft
"@grafana/scenes@npm:^5.11.1":
version: 5.11.1
resolution: "@grafana/scenes@npm:5.11.1"
version: 5.11.2
resolution: "@grafana/scenes@npm:5.11.2"
dependencies:
"@floating-ui/react": "npm:0.26.16"
"@grafana/e2e-selectors": "npm:^11.0.0"
@ -3951,7 +3951,7 @@ __metadata:
"@grafana/ui": ">=10.4"
react: ^18.0.0
react-dom: ^18.0.0
checksum: 10/15ec8bee9aa2aa8f5c64ed9fcaf4bd7c835162e0e63814556e7561e62462d5485f098131e411893e54ae3247692b348c8773a9459c30e45e936c5b0ef1a9d789
checksum: 10/1f6cded27acac813b1f039fa656efa476bcb2a444217c78c707441698d8d2dc053745fadcbad2dbe94a252d2613f1b32ac120fb11d887bb14f08a0bbea4c423b
languageName: node
linkType: hard

Loading…
Cancel
Save