Skip to content
Snippets Groups Projects
Verified Commit be86279a authored by Daniele Rossetti's avatar Daniele Rossetti :palm_tree: Committed by GitLab
Browse files

Add create issue button to metrics page

- Pass new issue path to metrics details view model
- Add Create Issue button to metrics details heading
- Build and inject observability_metric_details into URL param
parent 2b2c38a5
No related branches found
No related tags found
2 merge requests!162233Draft: Script to update Topology Service Gem,!161449Add create issue button to metrics page
Showing with 202 additions and 10 deletions
......@@ -43,6 +43,25 @@ export const periodToDate = (timePeriod) => {
return { min: new Date(maxMs - minMs), max: new Date(maxMs) };
};
/**
* Converts a time period string to a date range object.
*
* @param {string} timePeriod - The time period string (e.g., '5m', '3h', '7d').
* @returns {{value: string, startDate: Date, endDate: Date}} An object containing the date range.
* - value: Always set to CUSTOM_DATE_RANGE_OPTION.
* - startDate: The start date of the range.
* - endDate: The end date of the range (current time).
*/
export const periodToDateRange = (timePeriod) => {
const { min, max } = periodToDate(timePeriod);
return {
startDate: min,
endDate: max,
value: CUSTOM_DATE_RANGE_OPTION,
};
};
/**
* Validates the date range query parameters and returns an object with the validated date range.
*
......
<script>
import { GlLoadingIcon, GlEmptyState, GlSprintf } from '@gitlab/ui';
import { GlLoadingIcon, GlEmptyState, GlSprintf, GlButton } from '@gitlab/ui';
import EMPTY_CHART_SVG from '@gitlab/svgs/dist/illustrations/chart-empty-state.svg?url';
import { s__ } from '~/locale';
import { s__, __ } from '~/locale';
import { createAlert } from '~/alert';
import { visitUrl } from '~/lib/utils/url_utility';
import {
......@@ -20,6 +20,7 @@ import MetricsLineChart from './metrics_line_chart.vue';
import FilteredSearch from './filter_bar/metrics_filtered_search.vue';
import { filterObjToQuery, queryToFilterObj } from './filters';
import MetricsHeatMap from './metrics_heatmap.vue';
import { createIssueUrlWithMetricDetails } from './utils';
const VISUAL_HEATMAP = 'heatmap';
......@@ -31,6 +32,7 @@ export default {
metricType: s__('ObservabilityMetrics|Type'),
lastIngested: s__('ObservabilityMetrics|Last ingested'),
cancelledWarning: s__('ObservabilityMetrics|Metrics search has been cancelled.'),
createIssueTitle: __('Create issue'),
},
components: {
GlSprintf,
......@@ -41,6 +43,7 @@ export default {
UrlSync,
MetricsHeatMap,
PageHeading,
GlButton,
},
mixins: [InternalEvents.mixin()],
props: {
......@@ -60,6 +63,10 @@ export default {
required: true,
type: String,
},
createIssueUrl: {
required: true,
type: String,
},
},
data() {
return {
......@@ -110,6 +117,14 @@ export default {
noMetric() {
return !this.metricData || !this.metricData.length;
},
createIssueUrlWithQuery() {
return createIssueUrlWithMetricDetails({
metricName: this.metricId,
metricType: this.metricType,
filters: this.filters,
createIssueUrl: this.createIssueUrl,
});
},
},
created() {
this.validateAndFetch();
......@@ -220,6 +235,12 @@ export default {
<header>
<page-heading :heading="header.title">
<template #actions>
<gl-button category="secondary" :href="createIssueUrlWithQuery">
{{ $options.i18n.createIssueTitle }}
</gl-button>
</template>
<template #description>
<p class="gl-my-0 gl-text-primary">
<strong>{{ $options.i18n.metricType }}:&nbsp;</strong>{{ header.type }}
......
import { CUSTOM_DATE_RANGE_OPTION } from '~/observability/constants';
import { periodToDateRange } from '~/observability/utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { filterObjToQuery } from './filters';
export function createIssueUrlWithMetricDetails({
metricName,
metricType,
filters,
createIssueUrl,
}) {
const absoluteDateRange =
filters.dateRange.value === CUSTOM_DATE_RANGE_OPTION
? filters.dateRange
: periodToDateRange(filters.dateRange.value);
const queryWithUpdatedDateRange = filterObjToQuery({
...filters,
dateRange: absoluteDateRange,
});
const metricsDetails = {
fullUrl: mergeUrlParams(queryWithUpdatedDateRange, window.location.href, {
spreadArrays: true,
}),
name: metricName,
type: metricType,
timeframe: [absoluteDateRange.startDate.toUTCString(), absoluteDateRange.endDate.toUTCString()],
};
const query = {
observability_metric_details: JSON.stringify(metricsDetails),
};
return mergeUrlParams(query, createIssueUrl, {
spreadArrays: true,
});
}
......@@ -19,6 +19,10 @@ export default {
required: true,
type: String,
},
createIssueUrl: {
required: true,
type: String,
},
apiConfig: {
type: Object,
required: true,
......@@ -38,5 +42,6 @@ export default {
:metric-type="metricType"
:metrics-index-url="metricsIndexUrl"
:observability-client="observabilityClient"
:create-issue-url="createIssueUrl"
/>
</template>
......@@ -11,6 +11,7 @@ def observability_metrics_details_view_model(project, metric_id, metric_type)
model[:metricId] = metric_id
model[:metricType] = metric_type
model[:metricsIndexUrl] = namespace_project_metrics_path(project.group, project)
model[:createIssueUrl] = new_namespace_project_issue_path(project.group, project)
end
end
......
import { GlLoadingIcon, GlEmptyState, GlSprintf } from '@gitlab/ui';
import { GlLoadingIcon, GlEmptyState, GlSprintf, GlButton } from '@gitlab/ui';
import MetricsDetails from 'ee/metrics/details/metrics_details.vue';
import { createMockClient } from 'helpers/mock_observability_client';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
......@@ -27,6 +27,7 @@ describe('MetricsDetails', () => {
const METRIC_ID = 'test.metric';
const METRIC_TYPE = 'Sum';
const METRICS_INDEX_URL = 'https://www.gitlab.com/flightjs/Flight/-/metrics';
const CREATE_ISSUE_URL = 'https://www.gitlab.com/flightjs/Flight/-/issues/new';
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findMetricDetails = () => wrapper.findComponentByTestId('metric-details');
......@@ -50,6 +51,7 @@ describe('MetricsDetails', () => {
metricId: METRIC_ID,
metricType: METRIC_TYPE,
metricsIndexUrl: METRICS_INDEX_URL,
createIssueUrl: CREATE_ISSUE_URL,
};
const showToast = jest.fn();
......@@ -76,6 +78,8 @@ describe('MetricsDetails', () => {
const { bindInternalEventDocument } = useMockInternalEventsTracking();
beforeEach(() => {
setWindowLocation('?type=Sum');
jest.spyOn(urlUtility, 'isValidURL').mockReturnValue(true);
ingestedAtTimeAgo.mockReturnValue('3 days ago');
......@@ -502,6 +506,23 @@ describe('MetricsDetails', () => {
expect(ingestedAtTimeAgo).toHaveBeenCalledWith(mockSearchMetadata.last_ingested_at);
});
it('renders the create issue button', () => {
const button = findHeader().findComponent(GlButton);
expect(button.text()).toBe('Create issue');
const metricsDetails = {
fullUrl:
'http://test.host/?type=Sum&date_range=custom&date_start=2020-07-05T23%3A00%3A00.000Z&date_end=2020-07-06T00%3A00%3A00.000Z',
name: 'test.metric',
type: 'Sum',
timeframe: ['Sun, 05 Jul 2020 23:00:00 GMT', 'Mon, 06 Jul 2020 00:00:00 GMT'],
};
expect(button.attributes('href')).toBe(
`https://www.gitlab.com/flightjs/Flight/-/issues/new?observability_metric_details=${encodeURIComponent(
JSON.stringify(metricsDetails),
)}`,
);
});
describe('with no data', () => {
beforeEach(async () => {
observabilityClientMock.fetchMetric.mockResolvedValue([]);
......
import { createIssueUrlWithMetricDetails } from 'ee/metrics/details/utils';
import setWindowLocation from 'helpers/set_window_location_helper';
import { useFakeDate } from 'helpers/fake_date';
describe('createIssueUrlWithMetricDetails', () => {
useFakeDate('2024-08-01 11:00:00');
const metricName = 'Test Metric';
const metricType = 'Sum';
const createIssueUrl = 'https://example.com/issues/new';
const filters = {
dateRange: {
value: '5m',
},
groupBy: {
func: 'sum',
},
};
beforeEach(() => {
setWindowLocation(
'http://gdk.test:3443/flightjs/Flight/-/metrics/app.ads.ad_requests?type=Sum&date_range=5m',
);
});
it('returns a URL with the metric details', () => {
const metricsDetails = {
fullUrl:
'http://gdk.test:3443/flightjs/Flight/-/metrics/app.ads.ad_requests?type=Sum&date_range=custom&group_by_fn=sum&date_start=2024-08-01T10%3A55%3A00.000Z&date_end=2024-08-01T11%3A00%3A00.000Z',
name: metricName,
type: metricType,
timeframe: ['Thu, 01 Aug 2024 10:55:00 GMT', 'Thu, 01 Aug 2024 11:00:00 GMT'],
};
const expectedUrl = `https://example.com/issues/new?observability_metric_details=${encodeURIComponent(JSON.stringify(metricsDetails))}`;
expect(
createIssueUrlWithMetricDetails({ metricName, metricType, filters, createIssueUrl }),
).toBe(expectedUrl);
});
it('handles custom date range', () => {
const customDateRange = {
value: 'custom',
startDate: new Date('2023-01-01'),
endDate: new Date('2023-01-31'),
};
const expectedUrl = `https://example.com/issues/new?observability_metric_details=${encodeURIComponent(
JSON.stringify({
fullUrl:
'http://gdk.test:3443/flightjs/Flight/-/metrics/app.ads.ad_requests?type=Sum&date_range=custom&date_start=2023-01-01T00%3A00%3A00.000Z&date_end=2023-01-31T00%3A00%3A00.000Z',
name: metricName,
type: metricType,
timeframe: ['Sun, 01 Jan 2023 00:00:00 GMT', 'Tue, 31 Jan 2023 00:00:00 GMT'],
}),
)}`;
expect(
createIssueUrlWithMetricDetails({
metricName,
metricType,
filters: { dateRange: customDateRange },
createIssueUrl,
}),
).toBe(expectedUrl);
});
});
......@@ -9,6 +9,7 @@ describe('DetailsIndex', () => {
metricId: 'test.metric',
metricType: 'a-type',
metricsIndexUrl: 'https://example.com/metrics/index',
createIssueUrl: 'https://example.com/new/issue',
apiConfig: { ...mockApiConfig },
};
......@@ -34,6 +35,7 @@ describe('DetailsIndex', () => {
expect(detailsCmp.props('metricId')).toBe(props.metricId);
expect(detailsCmp.props('metricsIndexUrl')).toBe(props.metricsIndexUrl);
expect(detailsCmp.props('metricType')).toBe(props.metricType);
expect(detailsCmp.props('createIssueUrl')).toBe(props.createIssueUrl);
});
it('builds the observability client', () => {
......
......@@ -65,7 +65,8 @@
apiConfig: expected_api_config,
metricId: "test.metric",
metricType: "metric_type",
metricsIndexUrl: namespace_project_metrics_path(project.group, project)
metricsIndexUrl: namespace_project_metrics_path(project.group, project),
createIssueUrl: new_namespace_project_issue_path(project.group, project)
}.to_json
expect(helper.observability_metrics_details_view_model(project, "test.metric", "metric_type"))
......
......@@ -112,7 +112,8 @@
apiConfig: expected_api_config,
metricId: "test.metric",
metricType: "metric_type",
metricsIndexUrl: namespace_project_metrics_path(project.group, project)
metricsIndexUrl: namespace_project_metrics_path(project.group, project),
createIssueUrl: new_namespace_project_issue_path(project.group, project)
}.to_json
expect(element.attributes['data-view-model'].value).to eq(expected_view_model)
end
......
import {
periodToDate,
periodToDateRange,
dateFilterObjToQuery,
queryToDateFilterObj,
addTimeToDate,
......@@ -16,18 +17,16 @@ import {
TIME_RANGE_OPTIONS_VALUES,
} from '~/observability/constants';
const MOCK_NOW_DATE = new Date('2023-10-09 15:30:00');
const realDateNow = Date.now;
describe('periodToDate', () => {
const realDateNow = Date.now;
const MOCK_NOW_DATE = new Date('2023-10-09 15:30:00');
beforeEach(() => {
global.Date.now = jest.fn().mockReturnValue(MOCK_NOW_DATE);
});
afterEach(() => {
global.Date.now = realDateNow;
});
it.each`
periodLabel | period | expectedMinDate
${'minutes (m)'} | ${'30m'} | ${new Date('2023-10-09 15:00:00')}
......@@ -50,6 +49,23 @@ describe('periodToDate', () => {
});
});
describe('periodToDateRange', () => {
beforeEach(() => {
global.Date.now = jest.fn().mockReturnValue(MOCK_NOW_DATE);
});
afterEach(() => {
global.Date.now = realDateNow;
});
it('returns a date range object from period', () => {
expect(periodToDateRange('30m')).toEqual({
value: 'custom',
endDate: new Date('2023-10-09T15:30:00.000Z'),
startDate: new Date('2023-10-09T15:00:00.000Z'),
});
});
});
describe('queryToDateFilterObj', () => {
it('returns default date range if no query params provided', () => {
expect(queryToDateFilterObj({})).toEqual({ value: '1h' });
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment