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

This commit is contained in:
gamab 2024-09-03 14:18:49 +02:00
commit a1b6408127
No known key found for this signature in database
GPG Key ID: 88D8810B587562C1
39 changed files with 331 additions and 182 deletions

View File

@ -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"],

1
.github/CODEOWNERS vendored
View File

@ -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

View File

@ -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.

View File

@ -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/).

View File

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

View File

@ -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);

View File

@ -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)

View File

@ -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(),
})
}

View File

@ -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) {

View File

@ -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';

View File

@ -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');

View File

@ -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 || {};

View File

@ -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`);
}
};

View File

@ -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>
);
}

View File

@ -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 });
}

View File

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

View File

@ -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,

View File

@ -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),
}),

View File

@ -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);
});

View File

@ -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('/');

View File

@ -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}>

View File

@ -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 }) => (

View File

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

View File

@ -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>
);
}

View File

@ -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();
});
});

View File

@ -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>
)}

View File

@ -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;

View File

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

View File

@ -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(

View File

@ -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);

View File

@ -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);
}, []);
}

View File

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

View File

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

View File

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

View File

@ -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({

View File

@ -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',

View File

@ -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",

View File

@ -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": "Åūŧő řęƒřęşĥ",

View File

@ -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