From 8831e74fa357f4aa138ba70a5053a5903a3ad8cb Mon Sep 17 00:00:00 2001 From: Daniele Rossetti <drossetti@gitlab.com> Date: Tue, 1 Oct 2024 06:20:54 +0000 Subject: [PATCH] Fix broken dependency (Run yarn add modern-screenshot again) --- app/assets/javascripts/api/projects_api.js | 28 ++++ .../javascripts/lib/utils/image_utils.js | 5 + .../metrics/details/metrics_details.vue | 72 ++++++++-- .../metrics/details/metrics_heatmap.vue | 2 +- .../metrics/details/metrics_line_chart.vue | 6 +- .../metrics/details/metrics_snapshot.js | 20 +++ .../javascripts/metrics/details/utils.js | 21 ++- .../javascripts/metrics/details_index.vue | 5 + .../observability/metrics_issues_helper.rb | 12 +- .../helpers/projects/observability_helper.rb | 3 +- .../metrics/details/metrics_details_spec.js | 125 ++++++++++++++++-- .../metrics/details/metrics_snapshot_spec.js | 39 ++++++ .../frontend/metrics/details/utils_spec.js | 55 +++++++- .../frontend/metrics/details_index_spec.js | 2 + .../metrics_issues_helper_spec.rb | 16 +++ .../projects/observability_helper_spec.rb | 12 +- .../requests/projects/logs_controller_spec.rb | 1 + .../projects/metrics_controller_spec.rb | 4 +- .../projects/tracing_controller_spec.rb | 4 +- locale/gitlab.pot | 3 + package.json | 1 + spec/frontend/api/projects_api_spec.js | 85 ++++++++++++ yarn.lock | 36 ++--- 23 files changed, 494 insertions(+), 63 deletions(-) create mode 100644 ee/app/assets/javascripts/metrics/details/metrics_snapshot.js create mode 100644 ee/spec/frontend/metrics/details/metrics_snapshot_spec.js diff --git a/app/assets/javascripts/api/projects_api.js b/app/assets/javascripts/api/projects_api.js index e85553cc2923d..558520faf94a4 100644 --- a/app/assets/javascripts/api/projects_api.js +++ b/app/assets/javascripts/api/projects_api.js @@ -10,6 +10,7 @@ const PROJECT_IMPORT_MEMBERS_PATH = '/api/:version/projects/:id/import_project_m const PROJECT_REPOSITORY_SIZE_PATH = '/api/:version/projects/:id/repository_size'; const PROJECT_TRANSFER_LOCATIONS_PATH = 'api/:version/projects/:id/transfer_locations'; const PROJECT_SHARE_LOCATIONS_PATH = 'api/:version/projects/:id/share_locations'; +const PROJECT_UPLOADS_PATH = '/api/:version/projects/:id/uploads'; export function getProjects(query, options, callback = () => {}) { const url = buildApiUrl(PROJECTS_PATH); @@ -91,3 +92,30 @@ export const getProjectShareLocations = (projectId, params = {}, axiosOptions = return axios.get(url, { params: { ...defaultParams, ...params }, ...axiosOptions }); }; + +/** + * Uploads an image to a project and returns the share URL. + * + * @param {Object} options - The options for uploading the image. + * @param {string} options.filename - The name of the file to be uploaded. + * @param {Blob} options.blobData - The blob data of the image to be uploaded. + * @param {string} options.projectId - The ID of the project. + * @returns {Promise<string>} The share URL of the uploaded image + */ + +export async function uploadImageToProject({ filename, blobData, projectId }) { + const url = buildApiUrl(PROJECT_UPLOADS_PATH).replace(':id', projectId); + + const formData = new FormData(); + formData.append('file', blobData, filename); + + const result = await axios.post(url, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + + if (!result.data?.full_path) { + return Promise.reject(new Error(`Image failed to upload`)); + } + + return new URL(result.data.full_path, document.baseURI).href; +} diff --git a/app/assets/javascripts/lib/utils/image_utils.js b/app/assets/javascripts/lib/utils/image_utils.js index 53906a5ab8744..2a240a39945f1 100644 --- a/app/assets/javascripts/lib/utils/image_utils.js +++ b/app/assets/javascripts/lib/utils/image_utils.js @@ -1,3 +1,4 @@ +import { domToBlob } from 'modern-screenshot'; import vector from './vector'; import { readFileAsDataURL } from './file_utility'; @@ -64,3 +65,7 @@ export const getRetinaDimensions = async (pngFile) => { return null; } }; + +export function domElementToBlob(domElement) { + return domToBlob(domElement); +} diff --git a/ee/app/assets/javascripts/metrics/details/metrics_details.vue b/ee/app/assets/javascripts/metrics/details/metrics_details.vue index c747da42fd8ba..200a708aaf632 100644 --- a/ee/app/assets/javascripts/metrics/details/metrics_details.vue +++ b/ee/app/assets/javascripts/metrics/details/metrics_details.vue @@ -4,7 +4,7 @@ import EMPTY_CHART_SVG from '@gitlab/svgs/dist/illustrations/chart-empty-state.s import { uniqueId } from 'lodash'; import { s__, __ } from '~/locale'; import { createAlert } from '~/alert'; -import { visitUrl } from '~/lib/utils/url_utility'; +import { visitUrl, visitUrlWithAlerts } from '~/lib/utils/url_utility'; import { prepareTokens, processFilters as processFilteredSearchFilters, @@ -17,6 +17,8 @@ import PageHeading from '~/vue_shared/components/page_heading.vue'; import RelatedIssuesBadge from '~/observability/components/related_issues_badge.vue'; import RelatedIssue from '~/observability/components/observability_related_issues.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; +import { logError } from '~/lib/logger'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { ingestedAtTimeAgo } from '../utils'; import { VIEW_METRICS_DETAILS_PAGE } from '../events'; import MetricsLineChart from './metrics_line_chart.vue'; @@ -26,6 +28,7 @@ import MetricsHeatMap from './metrics_heatmap.vue'; import { createIssueUrlWithMetricDetails, isHistogram } from './utils'; import RelatedIssuesProvider from './related_issues/related_issues_provider.vue'; import RelatedTraces from './related_traces.vue'; +import { uploadMetricsSnapshot } from './metrics_snapshot'; const VISUAL_HEATMAP = 'heatmap'; @@ -38,6 +41,9 @@ export default { lastIngested: s__('ObservabilityMetrics|Last ingested'), cancelledWarning: s__('ObservabilityMetrics|Metrics search has been cancelled.'), createIssueTitle: __('Create issue'), + creatingSnapshotError: s__( + 'ObservabilityMetrics|Error: Unable to create metric snapshot image.', + ), }, components: { GlSprintf, @@ -80,6 +86,10 @@ export default { required: true, type: String, }, + projectId: { + required: true, + type: Number, + }, tracingIndexUrl: { type: String, required: true, @@ -94,6 +104,7 @@ export default { loading: false, queryCancelled: false, selectedDatapoints: [], + creatingIssue: false, }; }, computed: { @@ -129,14 +140,6 @@ 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(); @@ -235,6 +238,49 @@ export default { onChartSelected(datapoints) { this.selectedDatapoints = datapoints; }, + async uploadChartSnapshot() { + const chartDomElement = this.$refs.chartComponent?.$refs.chart; + + if (!chartDomElement) return ''; + + return uploadMetricsSnapshot(chartDomElement, this.projectId, { + metricName: this.metricId, + metricType: this.metricType, + filters: this.filters, + }); + }, + buildIssueUrl(imageSnapshotUrl = '') { + return createIssueUrlWithMetricDetails({ + metricName: this.metricId, + metricType: this.metricType, + filters: this.filters, + createIssueUrl: this.createIssueUrl, + imageSnapshotUrl, + }); + }, + async onCreateIssue() { + this.creatingIssue = true; + + try { + const imageSnapshotUrl = await this.uploadChartSnapshot(); + + visitUrl(this.buildIssueUrl(imageSnapshotUrl)); + } catch (error) { + // eslint-disable-next-line @gitlab/require-i18n-strings + logError('Unexpected error while uploading image', error); + Sentry.captureException(error); + + visitUrlWithAlerts(this.buildIssueUrl(), [ + { + id: 'metrics-snapshot-creation-failed', + message: this.$options.i18n.creatingSnapshotError, + variant: 'danger', + }, + ]); + } + + this.creatingIssue = false; + }, }, EMPTY_CHART_SVG, relatedIssuesHelpPath: helpPagePath('/operations/metrics', { @@ -267,7 +313,12 @@ export default { :error="error" :anchor-id="$options.relatedIssuesId" /> - <gl-button category="primary" variant="confirm" :href="createIssueUrlWithQuery"> + <gl-button + category="primary" + variant="confirm" + :loading="creatingIssue" + @click="onCreateIssue" + > {{ $options.i18n.createIssueTitle }} </gl-button> </template> @@ -301,6 +352,7 @@ export default { <div v-if="metricData && metricData.length"> <component :is="getChartComponent()" + ref="chartComponent" :metric-data="metricData" :loading="loading" :cancelled="queryCancelled" diff --git a/ee/app/assets/javascripts/metrics/details/metrics_heatmap.vue b/ee/app/assets/javascripts/metrics/details/metrics_heatmap.vue index 0c3891a1563e0..5936daf6495da 100644 --- a/ee/app/assets/javascripts/metrics/details/metrics_heatmap.vue +++ b/ee/app/assets/javascripts/metrics/details/metrics_heatmap.vue @@ -114,7 +114,7 @@ export default { </script> <template> - <div class="gl-relative"> + <div ref="chart" class="gl-relative"> <gl-heatmap :class="{ 'gl-opacity-3': loading || cancelled }" :x-axis-labels="xAxisLabels" diff --git a/ee/app/assets/javascripts/metrics/details/metrics_line_chart.vue b/ee/app/assets/javascripts/metrics/details/metrics_line_chart.vue index af49708abc273..478a681ee080d 100644 --- a/ee/app/assets/javascripts/metrics/details/metrics_line_chart.vue +++ b/ee/app/assets/javascripts/metrics/details/metrics_line_chart.vue @@ -49,9 +49,9 @@ export default { }, computed: { bgColor() { - return this.$refs.rootContainer + return this.$refs.chart ? window - .getComputedStyle(this.$refs.rootContainer) + .getComputedStyle(this.$refs.chart) .getPropertyValue('--gl-background-color-default') : '#fff'; }, @@ -166,7 +166,7 @@ export default { </script> <template> - <div ref="rootContainer" class="gl-relative"> + <div ref="chart" class="gl-relative"> <gl-line-chart disabled :class="{ 'gl-opacity-3': loading || cancelled }" diff --git a/ee/app/assets/javascripts/metrics/details/metrics_snapshot.js b/ee/app/assets/javascripts/metrics/details/metrics_snapshot.js new file mode 100644 index 0000000000000..e4ac1084b0121 --- /dev/null +++ b/ee/app/assets/javascripts/metrics/details/metrics_snapshot.js @@ -0,0 +1,20 @@ +import { domElementToBlob } from '~/lib/utils/image_utils'; +import { uploadImageToProject } from '~/api/projects_api'; +import { getAbsoluteDateRange, getTimeframe } from 'ee/metrics/details/utils'; +import { slugify } from '~/lib/utils/text_utility'; + +export async function uploadMetricsSnapshot(element, projectId, metricProperties) { + const timeFrame = getTimeframe(getAbsoluteDateRange(metricProperties.filters.dateRange)); + + // e.g. sum_metric_calls_tue-24-sep-2024-12-18-53-gmt_tue-24-sep-2024-13-18-53-gmt_snapshot.png + const filename = slugify( + `${metricProperties.metricType.toLowerCase()}_metric_${metricProperties.metricName}_${timeFrame.join('_')}_snapshot.png`, + ); + const blobData = await domElementToBlob(element); + + return uploadImageToProject({ + blobData, + filename, + projectId, + }); +} diff --git a/ee/app/assets/javascripts/metrics/details/utils.js b/ee/app/assets/javascripts/metrics/details/utils.js index 58906209249ac..982b955129552 100644 --- a/ee/app/assets/javascripts/metrics/details/utils.js +++ b/ee/app/assets/javascripts/metrics/details/utils.js @@ -5,16 +5,26 @@ import { tracingListQueryFromAttributes } from 'ee/tracing/list/filter_bar/filte import { METRIC_TYPE } from '../constants'; import { filterObjToQuery } from './filters'; +export function getAbsoluteDateRange(dateRange) { + if (dateRange.value === CUSTOM_DATE_RANGE_OPTION) { + return dateRange; + } + + return periodToDateRange(dateRange.value); +} + +export function getTimeframe(dateRange) { + return [dateRange.startDate.toUTCString(), dateRange.endDate.toUTCString()]; +} + export function createIssueUrlWithMetricDetails({ metricName, metricType, filters, createIssueUrl, + imageSnapshotUrl, }) { - const absoluteDateRange = - filters.dateRange.value === CUSTOM_DATE_RANGE_OPTION - ? filters.dateRange - : periodToDateRange(filters.dateRange.value); + const absoluteDateRange = getAbsoluteDateRange(filters.dateRange); const queryWithUpdatedDateRange = filterObjToQuery({ ...filters, @@ -27,7 +37,8 @@ export function createIssueUrlWithMetricDetails({ }), name: metricName, type: metricType, - timeframe: [absoluteDateRange.startDate.toUTCString(), absoluteDateRange.endDate.toUTCString()], + timeframe: getTimeframe(absoluteDateRange), + imageSnapshotUrl: imageSnapshotUrl || undefined, }; return createIssueUrlWithDetails(createIssueUrl, metricsDetails, 'observability_metric_details'); diff --git a/ee/app/assets/javascripts/metrics/details_index.vue b/ee/app/assets/javascripts/metrics/details_index.vue index 8879508057124..3d232c49ed70d 100644 --- a/ee/app/assets/javascripts/metrics/details_index.vue +++ b/ee/app/assets/javascripts/metrics/details_index.vue @@ -31,6 +31,10 @@ export default { type: String, required: true, }, + projectId: { + type: Number, + required: true, + }, tracingIndexUrl: { type: String, required: true, @@ -52,6 +56,7 @@ export default { :observability-client="observabilityClient" :create-issue-url="createIssueUrl" :project-full-path="projectFullPath" + :project-id="projectId" :tracing-index-url="tracingIndexUrl" /> </template> diff --git a/ee/app/helpers/observability/metrics_issues_helper.rb b/ee/app/helpers/observability/metrics_issues_helper.rb index 82ba0217289a8..05b2ffc544037 100644 --- a/ee/app/helpers/observability/metrics_issues_helper.rb +++ b/ee/app/helpers/observability/metrics_issues_helper.rb @@ -14,12 +14,22 @@ def observability_metrics_issues_params(params) private def observability_metrics_issue_description(params) - <<~TEXT + description = <<~TEXT [Metric details](#{params['fullUrl']}) \\ Name: `#{params['name']}` \\ Type: `#{params['type']}` \\ Timeframe: `#{params.dig('timeframe', 0)} - #{params.dig('timeframe', 1)}` TEXT + + if params['imageSnapshotUrl'].present? + description += <<~TEXT + | Snapshot | + | ------ | + |  | + TEXT + end + + description end end end diff --git a/ee/app/helpers/projects/observability_helper.rb b/ee/app/helpers/projects/observability_helper.rb index 4353dfbe9024f..150e4f6595e34 100644 --- a/ee/app/helpers/projects/observability_helper.rb +++ b/ee/app/helpers/projects/observability_helper.rb @@ -65,7 +65,8 @@ def shared_model(project) logsSearchUrl: ::Gitlab::Observability.logs_search_url(project), logsSearchMetadataUrl: ::Gitlab::Observability.logs_search_metadata_url(project) }, - projectFullPath: project.full_path + projectFullPath: project.full_path, + projectId: project.id } end end diff --git a/ee/spec/frontend/metrics/details/metrics_details_spec.js b/ee/spec/frontend/metrics/details/metrics_details_spec.js index 24c987501dc87..92e632d4f0050 100644 --- a/ee/spec/frontend/metrics/details/metrics_details_spec.js +++ b/ee/spec/frontend/metrics/details/metrics_details_spec.js @@ -21,6 +21,11 @@ import RelatedIssue from '~/observability/components/observability_related_issue import { stubComponent } from 'helpers/stub_component'; import { helpPagePath } from '~/helpers/help_page_helper'; import RelatedIssuesBadge from '~/observability/components/related_issues_badge.vue'; +import { uploadMetricsSnapshot } from 'ee/metrics/details/metrics_snapshot'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { logError } from '~/lib/logger'; + +jest.mock('ee/metrics/details/metrics_snapshot'); jest.mock('~/alert'); jest.mock('~/lib/utils/axios_utils'); @@ -28,6 +33,8 @@ jest.mock('ee/metrics/utils'); jest.mock('lodash/uniqueId', () => { return jest.fn((input) => `${input}1`); }); +jest.mock('~/lib/logger'); +jest.mock('~/sentry/sentry_browser_wrapper'); describe('MetricsDetails', () => { let wrapper; @@ -39,6 +46,7 @@ describe('MetricsDetails', () => { const createIssueUrl = 'https://www.gitlab.com/flightjs/Flight/-/issues/new'; const tracingIndexUrl = 'https://www.gitlab.com/flightjs/Flight/-/tracing'; const projectFullPath = 'test/project'; + const projectId = 1234; const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findMetricDetails = () => wrapper.findComponentByTestId('metric-details'); @@ -69,6 +77,7 @@ describe('MetricsDetails', () => { createIssueUrl, projectFullPath, tracingIndexUrl, + projectId, }; const showToast = jest.fn(); @@ -547,9 +556,13 @@ 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'); + describe('create issue', () => { + const findButton = () => findHeader().findComponent(GlButton); + const onButtonClicked = async () => { + await findButton().vm.$emit('click'); + await waitForPromises(); + }; + 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', @@ -557,11 +570,107 @@ describe('MetricsDetails', () => { type: 'Sum', timeframe: ['Sun, 05 Jul 2020 23:00:00 GMT', 'Mon, 06 Jul 2020 00:00:00 GMT'], }; - expect(button.attributes('href')).toBe( - `${createIssueUrl}?observability_metric_details=${encodeURIComponent( - JSON.stringify(metricsDetails), - )}&${encodeURIComponent('issue[confidential]')}=true`, - ); + + let visitUrlMock; + let visitUrlWithAlertsMock; + + beforeEach(() => { + visitUrlMock = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({}); + visitUrlWithAlertsMock = jest.spyOn(urlUtility, 'visitUrlWithAlerts').mockReturnValue({}); + wrapper.vm.$refs.chartComponent = { $refs: { chart: {} } }; + uploadMetricsSnapshot.mockResolvedValue('http://test.host/share/url'); + }); + + it('renders the create issue button', () => { + const button = findButton(); + expect(button.text()).toBe('Create issue'); + }); + + describe('on button clicked', () => { + beforeEach(async () => { + await onButtonClicked(); + }); + + it('uploads the metric snapshot', () => { + expect(uploadMetricsSnapshot).toHaveBeenCalledWith( + wrapper.vm.$refs.chartComponent.$refs.chart, + projectId, + { + metricName: metricsDetails.name, + metricType: metricsDetails.type, + filters: expect.objectContaining({ + dateRange: { value: '1h' }, + }), + }, + ); + }); + + it('does not add the loading icon to the create issue button when done uploading the snapshot', () => { + expect(findButton().props('loading')).toBe(false); + }); + + it('appends the share url to the create issue params if uploadMetricsSnapshot succeeds', () => { + expect(visitUrlMock).toHaveBeenCalledWith( + `${createIssueUrl}?observability_metric_details=${encodeURIComponent( + JSON.stringify({ ...metricsDetails, imageSnapshotUrl: 'http://test.host/share/url' }), + )}&${encodeURIComponent('issue[confidential]')}=true`, + ); + }); + }); + + it('redirects to the create issue page without the image url if share url is not returned', async () => { + uploadMetricsSnapshot.mockResolvedValue(null); + + await onButtonClicked(); + + expect(visitUrlMock).toHaveBeenCalledWith( + `${createIssueUrl}?observability_metric_details=${encodeURIComponent( + JSON.stringify(metricsDetails), + )}&${encodeURIComponent('issue[confidential]')}=true`, + ); + }); + + it('does not upload the metric snapshot if the chart does not exist', async () => { + wrapper.vm.$refs.chartComponent = { $refs: { chart: null } }; + + await onButtonClicked(); + + expect(uploadMetricsSnapshot).not.toHaveBeenCalled(); + }); + + it('redirects to the create issue page with alerts if an unexpected error is thrown', async () => { + const mockError = new Error('Upload failed'); + + uploadMetricsSnapshot.mockRejectedValue(mockError); + + await onButtonClicked(); + + expect(logError).toHaveBeenCalledWith('Unexpected error while uploading image', mockError); + expect(Sentry.captureException).toHaveBeenCalledWith(mockError); + expect(visitUrlWithAlertsMock).toHaveBeenCalledWith( + `${createIssueUrl}?observability_metric_details=${encodeURIComponent( + JSON.stringify(metricsDetails), + )}&${encodeURIComponent('issue[confidential]')}=true`, + [ + { + id: 'metrics-snapshot-creation-failed', + message: 'Error: Unable to create metric snapshot image.', + variant: 'danger', + }, + ], + ); + }); + + describe('while uploading the snapshot', () => { + beforeEach(async () => { + uploadMetricsSnapshot.mockReturnValue(new Promise(() => {})); + await onButtonClicked(); + }); + + it('adds a loading icon to the create issue button while uploading the snapshot', () => { + expect(findButton().props('loading')).toBe(true); + }); + }); }); it('renders the relate issues badge', () => { diff --git a/ee/spec/frontend/metrics/details/metrics_snapshot_spec.js b/ee/spec/frontend/metrics/details/metrics_snapshot_spec.js new file mode 100644 index 0000000000000..debc911ae46e4 --- /dev/null +++ b/ee/spec/frontend/metrics/details/metrics_snapshot_spec.js @@ -0,0 +1,39 @@ +import { uploadMetricsSnapshot } from 'ee/metrics/details/metrics_snapshot'; +import { domElementToBlob } from '~/lib/utils/image_utils'; +import { uploadImageToProject } from '~/api/projects_api'; +import { useFakeDate } from 'helpers/fake_date'; + +jest.mock('~/lib/utils/image_utils'); +jest.mock('~/api/projects_api'); + +describe('uploadMetricsSnapshot', () => { + useFakeDate('2024-08-01 11:00:00'); + + const mockElement = document.createElement('div'); + const mockProjectId = 123; + const mockBlobData = new Blob(['mock data']); + const mockShareUrl = 'https://example.com/share/image.png'; + const metricProperties = { + metricName: 'test.metric', + metricType: 'Sum', + filters: { dateRange: { value: '5m' } }, + }; + + beforeEach(() => { + domElementToBlob.mockResolvedValue(mockBlobData); + uploadImageToProject.mockResolvedValue(mockShareUrl); + }); + + it('should upload a metrics snapshot', async () => { + const result = await uploadMetricsSnapshot(mockElement, mockProjectId, metricProperties); + + expect(domElementToBlob).toHaveBeenCalledWith(mockElement); + expect(uploadImageToProject).toHaveBeenCalledWith({ + blobData: mockBlobData, + filename: + 'sum_metric_test.metric_thu-01-aug-2024-10-55-00-gmt_thu-01-aug-2024-11-00-00-gmt_snapshot.png', + projectId: mockProjectId, + }); + expect(result).toBe(mockShareUrl); + }); +}); diff --git a/ee/spec/frontend/metrics/details/utils_spec.js b/ee/spec/frontend/metrics/details/utils_spec.js index e907c18ccc8c2..42f46d7f40ab3 100644 --- a/ee/spec/frontend/metrics/details/utils_spec.js +++ b/ee/spec/frontend/metrics/details/utils_spec.js @@ -1,4 +1,6 @@ import { + getAbsoluteDateRange, + getTimeframe, createIssueUrlWithMetricDetails, viewTracesUrlWithMetric, isHistogram, @@ -6,12 +8,56 @@ import { import setWindowLocation from 'helpers/set_window_location_helper'; import { useFakeDate } from 'helpers/fake_date'; +describe('getAbsoluteDateRange', () => { + useFakeDate('2024-08-01 11:00:00'); + + it('returns the absolute date range for custom dates', () => { + const dateRange = { + value: 'custom', + startDate: new Date('2023-01-01'), + endDate: new Date('2023-01-31'), + }; + + expect(getAbsoluteDateRange(dateRange)).toStrictEqual({ + endDate: new Date('2023-01-31'), + startDate: new Date('2023-01-01'), + value: 'custom', + }); + }); + + it('returns the absolute date range for date periods', () => { + const dateRange = { value: '5m' }; + + expect(getAbsoluteDateRange(dateRange)).toStrictEqual({ + endDate: new Date('2024-08-01 11:00:00'), + startDate: new Date('2024-08-01 10:55:00'), + value: 'custom', + }); + }); +}); + +describe('getTimeframe', () => { + it('returns the timeframe array', () => { + const dateRange = { + value: 'custom', + startDate: new Date('2023-01-01'), + endDate: new Date('2023-01-31'), + }; + + expect(getTimeframe(dateRange)).toStrictEqual([ + 'Sun, 01 Jan 2023 00:00:00 GMT', + 'Tue, 31 Jan 2023 00:00:00 GMT', + ]); + }); +}); + describe('createIssueUrlWithMetricDetails', () => { useFakeDate('2024-08-01 11:00:00'); const metricName = 'Test Metric'; const metricType = 'Sum'; const createIssueUrl = 'https://example.com/issues/new'; + const imageSnapshotUrl = 'https://example.com/image.png'; const filters = { dateRange: { value: '5m', @@ -34,11 +80,18 @@ describe('createIssueUrlWithMetricDetails', () => { name: metricName, type: metricType, timeframe: ['Thu, 01 Aug 2024 10:55:00 GMT', 'Thu, 01 Aug 2024 11:00:00 GMT'], + imageSnapshotUrl, }; const expectedUrl = `https://example.com/issues/new?observability_metric_details=${encodeURIComponent(JSON.stringify(metricsDetails))}&${encodeURIComponent('issue[confidential]')}=true`; expect( - createIssueUrlWithMetricDetails({ metricName, metricType, filters, createIssueUrl }), + createIssueUrlWithMetricDetails({ + metricName, + metricType, + filters, + createIssueUrl, + imageSnapshotUrl, + }), ).toBe(expectedUrl); }); diff --git a/ee/spec/frontend/metrics/details_index_spec.js b/ee/spec/frontend/metrics/details_index_spec.js index d0463c9d55e8c..54787a94096f1 100644 --- a/ee/spec/frontend/metrics/details_index_spec.js +++ b/ee/spec/frontend/metrics/details_index_spec.js @@ -13,6 +13,7 @@ describe('DetailsIndex', () => { tracingIndexUrl: 'https://example.com/traces/index', apiConfig: { ...mockApiConfig }, projectFullPath: 'foo/bar', + projectId: 1234, }; let wrapper; @@ -39,6 +40,7 @@ describe('DetailsIndex', () => { expect(detailsCmp.props('metricType')).toBe(props.metricType); expect(detailsCmp.props('createIssueUrl')).toBe(props.createIssueUrl); expect(detailsCmp.props('projectFullPath')).toBe(props.projectFullPath); + expect(detailsCmp.props('projectId')).toBe(props.projectId); expect(detailsCmp.props('tracingIndexUrl')).toBe(props.tracingIndexUrl); }); diff --git a/ee/spec/helpers/observability/metrics_issues_helper_spec.rb b/ee/spec/helpers/observability/metrics_issues_helper_spec.rb index 12a26afbb4d08..27350b5e9c8ae 100644 --- a/ee/spec/helpers/observability/metrics_issues_helper_spec.rb +++ b/ee/spec/helpers/observability/metrics_issues_helper_spec.rb @@ -35,6 +35,22 @@ TEXT ) end + + it 'adds the image to the text description if imageSnapshotUrl is present' do + params['imageSnapshotUrl'] = 'http://example.com/image.png' + result = helper.observability_metrics_issues_params(params) + expect(result[:description]).to eq( + <<~TEXT + [Metric details](http://example.com/metric/123) \\ + Name: `CPU Usage High` \\ + Type: `gauge` \\ + Timeframe: `2024-08-14T00:00:00Z - 2024-08-14T23:59:59Z` + | Snapshot | + | ------ | + |  | + TEXT + ) + end end end end diff --git a/ee/spec/helpers/projects/observability_helper_spec.rb b/ee/spec/helpers/projects/observability_helper_spec.rb index bfd8cd3ac9488..b42c4c88187ef 100644 --- a/ee/spec/helpers/projects/observability_helper_spec.rb +++ b/ee/spec/helpers/projects/observability_helper_spec.rb @@ -28,7 +28,8 @@ it 'generates the correct JSON' do expected_json = { apiConfig: expected_api_config, - projectFullPath: project.full_path + projectFullPath: project.full_path, + projectId: project.id }.to_json expect(helper.observability_tracing_view_model(project)).to eq(expected_json) @@ -40,6 +41,7 @@ expected_json = { apiConfig: expected_api_config, projectFullPath: project.full_path, + projectId: project.id, traceId: "trace-id", tracingIndexUrl: namespace_project_tracing_index_path(project.group, project), logsIndexUrl: namespace_project_logs_path(project.group, project), @@ -55,7 +57,8 @@ it 'generates the correct JSON' do expected_json = { apiConfig: expected_api_config, - projectFullPath: project.full_path + projectFullPath: project.full_path, + projectId: project.id }.to_json expect(helper.observability_metrics_view_model(project)).to eq(expected_json) @@ -67,6 +70,7 @@ expected_json = { apiConfig: expected_api_config, projectFullPath: project.full_path, + projectId: project.id, metricId: "test.metric", metricType: "metric_type", metricsIndexUrl: namespace_project_metrics_path(project.group, project), @@ -84,6 +88,7 @@ expected_json = { apiConfig: expected_api_config, projectFullPath: project.full_path, + projectId: project.id, tracingIndexUrl: namespace_project_tracing_index_path(project.group, project), createIssueUrl: new_namespace_project_issue_path(project.group, project) }.to_json @@ -96,7 +101,8 @@ it 'generates the correct JSON' do expected_json = { apiConfig: expected_api_config, - projectFullPath: project.full_path + projectFullPath: project.full_path, + projectId: project.id }.to_json expect(helper.observability_usage_quota_view_model(project)).to eq(expected_json) diff --git a/ee/spec/requests/projects/logs_controller_spec.rb b/ee/spec/requests/projects/logs_controller_spec.rb index d57a268eda711..4b57efdc01cd5 100644 --- a/ee/spec/requests/projects/logs_controller_spec.rb +++ b/ee/spec/requests/projects/logs_controller_spec.rb @@ -80,6 +80,7 @@ expected_view_model = { apiConfig: expected_api_config, projectFullPath: project.full_path, + projectId: project.id, tracingIndexUrl: namespace_project_tracing_index_path(project.group, project), createIssueUrl: new_namespace_project_issue_path(project.group, project) }.to_json diff --git a/ee/spec/requests/projects/metrics_controller_spec.rb b/ee/spec/requests/projects/metrics_controller_spec.rb index 0a6f073478327..4f8a5c2d4feb3 100644 --- a/ee/spec/requests/projects/metrics_controller_spec.rb +++ b/ee/spec/requests/projects/metrics_controller_spec.rb @@ -79,7 +79,8 @@ expected_view_model = { apiConfig: expected_api_config, - projectFullPath: project.full_path + projectFullPath: project.full_path, + projectId: project.id }.to_json expect(element.attributes['data-view-model'].value).to eq(expected_view_model) end @@ -102,6 +103,7 @@ expected_view_model = { apiConfig: expected_api_config, projectFullPath: project.full_path, + projectId: project.id, metricId: "test.metric", metricType: "metric_type", metricsIndexUrl: namespace_project_metrics_path(project.group, project), diff --git a/ee/spec/requests/projects/tracing_controller_spec.rb b/ee/spec/requests/projects/tracing_controller_spec.rb index 905a8ec19e77e..5646b37f3cddf 100644 --- a/ee/spec/requests/projects/tracing_controller_spec.rb +++ b/ee/spec/requests/projects/tracing_controller_spec.rb @@ -79,7 +79,8 @@ expected_view_model = { apiConfig: expected_api_config, - projectFullPath: project.full_path + projectFullPath: project.full_path, + projectId: project.id }.to_json expect(element.attributes['data-view-model'].value).to eq(expected_view_model) end @@ -102,6 +103,7 @@ expected_view_model = { apiConfig: expected_api_config, projectFullPath: project.full_path, + projectId: project.id, traceId: 'test-trace-id', tracingIndexUrl: project_tracing_index_path(project), logsIndexUrl: namespace_project_logs_path(project.group, project), diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 438809ccddc10..d86046ae20d1a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -37229,6 +37229,9 @@ msgstr "" msgid "ObservabilityMetrics|Error: Failed to load metrics details. Try reloading the page." msgstr "" +msgid "ObservabilityMetrics|Error: Unable to create metric snapshot image." +msgstr "" + msgid "ObservabilityMetrics|Failed to load metrics." msgstr "" diff --git a/package.json b/package.json index 518679ae28b2c..d57bcac271bc2 100644 --- a/package.json +++ b/package.json @@ -184,6 +184,7 @@ "mermaid": "10.7.0", "micromatch": "^4.0.5", "minimatch": "^3.0.4", + "modern-screenshot": "^4.4.39", "monaco-editor": "^0.30.1", "monaco-editor-webpack-plugin": "^6.0.0", "monaco-yaml": "4.0.0", diff --git a/spec/frontend/api/projects_api_spec.js b/spec/frontend/api/projects_api_spec.js index 7dd686d04cf3e..76e98165f9290 100644 --- a/spec/frontend/api/projects_api_spec.js +++ b/spec/frontend/api/projects_api_spec.js @@ -219,4 +219,89 @@ describe('~/api/projects_api.js', () => { expect(mock.history.get[0].mockOption).toBe(axiosOptions.mockOption); }); }); + + describe('uploadImageToProject', () => { + const mockProjectId = 123; + const mockFilename = 'test.jpg'; + const mockBlobData = new Blob(['test']); + + beforeEach(() => { + window.gon = { relative_url_root: '', api_version: 'v7' }; + jest.spyOn(axios, 'post'); + }); + + it('should upload an image and return the share URL', async () => { + const mockResponse = { + full_path: '/-/project/123/uploads/abcd/test.jpg', + }; + + mock.onPost().replyOnce(HTTP_STATUS_OK, mockResponse); + + const result = await projectsApi.uploadImageToProject({ + filename: mockFilename, + blobData: mockBlobData, + projectId: mockProjectId, + }); + + expect(axios.post).toHaveBeenCalledWith( + `/api/v7/projects/${mockProjectId}/uploads`, + expect.any(FormData), + expect.objectContaining({ + headers: { 'Content-Type': 'multipart/form-data' }, + }), + ); + expect(result).toBe('http://test.host/-/project/123/uploads/abcd/test.jpg'); + }); + + it('should throw an error if filename is missing', async () => { + await expect( + projectsApi.uploadImageToProject({ + blobData: mockBlobData, + projectId: mockProjectId, + }), + ).rejects.toThrow('Request failed with status code 404'); + }); + + it('should throw an error if blobData is missing', async () => { + await expect( + projectsApi.uploadImageToProject({ + filename: mockFilename, + projectId: mockProjectId, + }), + ).rejects.toThrow("is not of type 'Blob'"); + }); + + it('should throw an error if projectId is missing', async () => { + await expect( + projectsApi.uploadImageToProject({ + filename: mockFilename, + blobData: mockBlobData, + }), + ).rejects.toThrow('Request failed with status code 404'); + }); + + it('should throw an error if the upload fails', async () => { + mock.onPost().replyOnce(500); + + await expect( + projectsApi.uploadImageToProject({ + filename: mockFilename, + blobData: mockBlobData, + projectId: mockProjectId, + }), + ).rejects.toThrow('Request failed with status code 500'); + }); + + it('should throw an error if the response does not have a link', async () => { + mock.onPost().replyOnce(HTTP_STATUS_OK, {}); + + await expect( + projectsApi.uploadImageToProject({ + filename: mockFilename, + blobData: mockBlobData, + projectId: mockProjectId, + }), + ).rejects.toThrow('Image failed to upload'); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index 8e12d95cdfa40..20d8901180ad2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10601,6 +10601,11 @@ mock-apollo-client@1.2.0: resolved "https://registry.yarnpkg.com/mock-apollo-client/-/mock-apollo-client-1.2.0.tgz#72543df0d74577d29be1b34cecba8898c7e71451" integrity sha512-zCVHv3p7zvUmen9zce9l965ZrI6rMbrm2/oqGaTerVYOaYskl/cVgTG/L7iIToTIpI7onk/f6tu8hxPXZdyy/g== +modern-screenshot@^4.4.39: + version "4.4.39" + resolved "https://registry.yarnpkg.com/modern-screenshot/-/modern-screenshot-4.4.39.tgz#4c8b7a9ecb899e68b6d4111abfa71043f326847a" + integrity sha512-p+I4yLZUDnoJMa5zoi+71nLQmoLQ6WRU4W8vZu1BZk2PlIYOz5mGnj9/7t2lGWKYeOr4zo6pajhY0/9TS5Zcdw== + monaco-editor-webpack-plugin@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/monaco-editor-webpack-plugin/-/monaco-editor-webpack-plugin-6.0.0.tgz#628956ce1851afa2a5f6c88d0ecbb24e9a444898" @@ -13111,16 +13116,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -13173,7 +13169,7 @@ string_decoder@^1.0.0, string_decoder@^1.1.1, string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -13187,13 +13183,6 @@ strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -14885,7 +14874,7 @@ worker-loader@^3.0.8: loader-utils "^2.0.0" schema-utils "^3.0.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -14903,15 +14892,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" -- GitLab