Skip to content
Snippets Groups Projects
Unverified Commit 56e2a291 authored by Marin Jankovski's avatar Marin Jankovski
Browse files

Merge branch 'master' of gitlab.com:gitlab-org/gitlab-ce

parents 104b9e8b c384315a
No related branches found
No related tags found
No related merge requests found
Showing
with 238 additions and 147 deletions
Loading
Loading
@@ -17,7 +17,7 @@ export default {
},
computed: {
queryInfo() {
return this.graphData.queries[0];
return this.graphData.metrics[0];
},
engineeringNotation() {
return `${roundOffFloat(this.queryInfo.result[0].value[1], 1)}${this.queryInfo.unit}`;
Loading
Loading
Loading
Loading
@@ -105,7 +105,7 @@ export default {
// Transforms & supplements query data to render appropriate labels & styles
// Input: [{ queryAttributes1 }, { queryAttributes2 }]
// Output: [{ seriesAttributes1 }, { seriesAttributes2 }]
return this.graphData.queries.reduce((acc, query) => {
return this.graphData.metrics.reduce((acc, query) => {
const { appearance } = query;
const lineType =
appearance && appearance.line && appearance.line.type
Loading
Loading
@@ -121,7 +121,7 @@ export default {
? appearance.area.opacity
: undefined,
};
const series = makeDataSeries(query.result, {
const series = makeDataSeries(query.result || [], {
name: this.formatLegendLabel(query),
lineStyle: {
type: lineType,
Loading
Loading
Loading
Loading
@@ -11,19 +11,19 @@ import {
GlModalDirective,
GlTooltipDirective,
} from '@gitlab/ui';
import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import { s__ } from '~/locale';
import createFlash from '~/flash';
import Icon from '~/vue_shared/components/icon.vue';
import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue';
import DateTimePicker from './date_time_picker/date_time_picker.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue';
import MonitorSingleStatChart from './charts/single_stat.vue';
import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import { getTimeDiff, isValidDate, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
import { getTimeDiff, isValidDate, getAddMetricTrackingOptions } from '../utils';
 
export default {
components: {
Loading
Loading
@@ -252,14 +252,9 @@ export default {
'setEndpoints',
'setPanelGroupMetrics',
]),
chartsWithData(charts) {
return charts.filter(chart =>
chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)),
);
},
updateMetrics(key, metrics) {
updateMetrics(key, panels) {
this.setPanelGroupMetrics({
metrics,
panels,
key,
});
},
Loading
Loading
@@ -294,14 +289,18 @@ export default {
submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit();
},
chartsWithData(panels) {
return panels.filter(panel =>
panel.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)),
);
},
groupHasData(group) {
return this.chartsWithData(group.metrics).length > 0;
return this.chartsWithData(group.panels).length > 0;
},
onDateTimePickerApply(timeWindowUrlParams) {
return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href));
},
downloadCSVOptions,
generateLinkToChartOptions,
getAddMetricTrackingOptions,
},
addMetric: {
title: s__('Metrics|Add metric'),
Loading
Loading
@@ -393,9 +392,10 @@ export default {
</gl-button>
<gl-button
v-if="addingMetricsAvailable"
ref="addMetricBtn"
v-gl-modal="$options.addMetric.modalId"
variant="outline-success"
class="mr-2 mt-1 js-add-metric-button"
class="mr-2 mt-1"
>
{{ $options.addMetric.title }}
</gl-button>
Loading
Loading
@@ -415,6 +415,8 @@ export default {
<div slot="modal-footer">
<gl-button @click="hideAddMetricModal">{{ __('Cancel') }}</gl-button>
<gl-button
ref="submitCustomMetricsFormBtn"
v-track-event="getAddMetricTrackingOptions()"
:disabled="!formIsValid"
variant="success"
@click="submitCustomMetricsForm"
Loading
Loading
@@ -457,14 +459,14 @@ export default {
:collapse-group="groupHasData(groupData)"
>
<vue-draggable
:value="groupData.metrics"
:value="groupData.panels"
group="metrics-dashboard"
:component-data="{ attrs: { class: 'row mx-0 w-100' } }"
:disabled="!isRearrangingPanels"
@input="updateMetrics(groupData.key, $event)"
>
<div
v-for="(graphData, graphIndex) in groupData.metrics"
v-for="(graphData, graphIndex) in groupData.panels"
:key="`panel-type-${graphIndex}`"
class="col-12 col-lg-6 px-2 mb-2 draggable"
:class="{ 'draggable-enabled': isRearrangingPanels }"
Loading
Loading
@@ -473,7 +475,7 @@ export default {
<div
v-if="isRearrangingPanels"
class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end"
@click="removeGraph(groupData.metrics, graphIndex)"
@click="removeGraph(groupData.panels, graphIndex)"
>
<a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"
><icon name="close"
Loading
Loading
Loading
Loading
@@ -37,11 +37,14 @@ export default {
computed: {
...mapState('monitoringDashboard', ['dashboard', 'metricsWithData']),
charts() {
if (!this.dashboard || !this.dashboard.panel_groups) {
return [];
}
const groupWithMetrics = this.dashboard.panel_groups.find(group =>
group.metrics.find(chart => this.chartHasData(chart)),
) || { metrics: [] };
group.panels.find(chart => this.chartHasData(chart)),
) || { panels: [] };
 
return groupWithMetrics.metrics.filter(chart => this.chartHasData(chart));
return groupWithMetrics.panels.filter(chart => this.chartHasData(chart));
},
isSingleChart() {
return this.charts.length === 1;
Loading
Loading
Loading
Loading
@@ -54,10 +54,14 @@ export default {
return IS_EE && this.prometheusAlertsAvailable && this.alertsEndpoint && this.graphData;
},
graphDataHasMetrics() {
return this.graphData.queries[0].result.length > 0;
return (
this.graphData.metrics &&
this.graphData.metrics[0].result &&
this.graphData.metrics[0].result.length > 0
);
},
csvText() {
const chartData = this.graphData.queries[0].result[0].values;
const chartData = this.graphData.metrics[0].result[0].values;
const yLabel = this.graphData.y_label;
const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings
return chartData.reduce((csv, data) => {
Loading
Loading
@@ -112,7 +116,7 @@ export default {
:graph-data="graphData"
:deployment-data="deploymentData"
:project-path="projectPath"
:thresholds="getGraphAlertValues(graphData.queries)"
:thresholds="getGraphAlertValues(graphData.metrics)"
group-id="panel-type-chart"
>
<div class="d-flex align-items-center">
Loading
Loading
@@ -120,8 +124,8 @@ export default {
v-if="alertWidgetAvailable && graphData"
:modal-id="`alert-modal-${index}`"
:alerts-endpoint="alertsEndpoint"
:relevant-queries="graphData.queries"
:alerts-to-manage="getGraphAlerts(graphData.queries)"
:relevant-queries="graphData.metrics"
:alerts-to-manage="getGraphAlerts(graphData.metrics)"
@setAlerts="setAlerts"
/>
<gl-dropdown
Loading
Loading
import Vue from 'vue';
import { slugify } from '~/lib/utils/text_utility';
import * as types from './mutation_types';
import { normalizeMetrics, normalizeMetric, normalizeQueryResult } from './utils';
import { normalizeMetric, normalizeQueryResult } from './utils';
 
const normalizePanel = panel => panel.metrics.map(normalizeMetric);
const normalizePanelMetrics = (metrics, defaultLabel) =>
metrics.map(metric => ({
...normalizeMetric(metric),
label: metric.label || defaultLabel,
}));
 
export default {
[types.REQUEST_METRICS_DATA](state) {
Loading
Loading
@@ -13,28 +17,18 @@ export default {
[types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) {
state.dashboard.panel_groups = groupData.map((group, i) => {
const key = `${slugify(group.group || 'default')}-${i}`;
let { metrics = [], panels = [] } = group;
let { panels = [] } = group;
 
// each panel has metric information that needs to be normalized
panels = panels.map(panel => ({
...panel,
metrics: normalizePanel(panel),
}));
// for backwards compatibility, and to limit Vue template changes:
// for each group alias panels to metrics
// for each panel alias metrics to queries
metrics = panels.map(panel => ({
...panel,
queries: panel.metrics,
metrics: normalizePanelMetrics(panel.metrics, panel.y_label),
}));
 
return {
...group,
panels,
key,
metrics: normalizeMetrics(metrics),
};
});
 
Loading
Loading
@@ -58,6 +52,7 @@ export default {
[types.RECEIVE_ENVIRONMENTS_DATA_FAILURE](state) {
state.environments = [];
},
[types.SET_QUERY_RESULT](state, { metricId, result }) {
if (!metricId || !result || result.length === 0) {
return;
Loading
Loading
@@ -65,14 +60,17 @@ export default {
 
state.showEmptyState = false;
 
/**
* Search the dashboard state for a matching id
*/
state.dashboard.panel_groups.forEach(group => {
group.metrics.forEach(metric => {
metric.queries.forEach(query => {
if (query.metric_id === metricId) {
group.panels.forEach(panel => {
panel.metrics.forEach(metric => {
if (metric.metric_id === metricId) {
state.metricsWithData.push(metricId);
// ensure dates/numbers are correctly formatted for charts
const normalizedResults = result.map(normalizeQueryResult);
Vue.set(query, 'result', Object.freeze(normalizedResults));
Vue.set(metric, 'result', Object.freeze(normalizedResults));
}
});
});
Loading
Loading
@@ -101,6 +99,6 @@ export default {
},
[types.SET_PANEL_GROUP_METRICS](state, payload) {
const panelGroup = state.dashboard.panel_groups.find(pg => payload.key === pg.key);
panelGroup.metrics = payload.metrics;
panelGroup.panels = payload.panels;
},
};
import _ from 'underscore';
 
function checkQueryEmptyData(query) {
return {
...query,
result: query.result.filter(timeSeries => {
const newTimeSeries = timeSeries;
const hasValue = series =>
!Number.isNaN(series[1]) && (series[1] !== null || series[1] !== undefined);
const hasNonNullValue = timeSeries.values.find(hasValue);
newTimeSeries.values = hasNonNullValue ? newTimeSeries.values : [];
return newTimeSeries.values.length > 0;
}),
};
}
function removeTimeSeriesNoData(queries) {
return queries.reduce((series, query) => series.concat(checkQueryEmptyData(query)), []);
}
// Metrics and queries are currently stored 1:1, so `queries` is an array of length one.
// We want to group queries onto a single chart by title & y-axis label.
// This function will no longer be required when metrics:queries are 1:many,
// though there is no consequence if the function stays in use.
// @param metrics [Array<Object>]
// Ex) [
// { id: 1, title: 'title', y_label: 'MB', queries: [{ ...query1Attrs }] },
// { id: 2, title: 'title', y_label: 'MB', queries: [{ ...query2Attrs }] },
// { id: 3, title: 'new title', y_label: 'MB', queries: [{ ...query3Attrs }] }
// ]
// @return [Array<Object>]
// Ex) [
// { title: 'title', y_label: 'MB', queries: [{ metricId: 1, ...query1Attrs },
// { metricId: 2, ...query2Attrs }] },
// { title: 'new title', y_label: 'MB', queries: [{ metricId: 3, ...query3Attrs }]}
// ]
export function groupQueriesByChartInfo(metrics) {
const metricsByChart = metrics.reduce((accumulator, metric) => {
const { queries, ...chart } = metric;
const chartKey = `${chart.title}|${chart.y_label}`;
accumulator[chartKey] = accumulator[chartKey] || { ...chart, queries: [] };
queries.forEach(queryAttrs => {
let metricId;
if (chart.id) {
metricId = chart.id.toString();
} else if (queryAttrs.metric_id) {
metricId = queryAttrs.metric_id.toString();
} else {
metricId = null;
}
accumulator[chartKey].queries.push({ metricId, ...queryAttrs });
});
return accumulator;
}, {});
return Object.values(metricsByChart);
}
export const uniqMetricsId = metric => `${metric.metric_id}_${metric.id}`;
 
/**
* Not to confuse with normalizeMetrics (plural)
* Metrics loaded from project-defined dashboards do not have a metric_id.
* This method creates a unique ID combining metric_id and id, if either is present.
* This is hopefully a temporary solution until BE processes metrics before passing to fE
* @param {Object} metric - metric
* @returns {Object} - normalized metric with a uniqueID
*/
export const normalizeMetric = (metric = {}) =>
_.omit(
{
...metric,
metric_id: uniqMetricsId(metric),
metricId: uniqMetricsId(metric),
},
'id',
);
Loading
Loading
@@ -93,6 +31,11 @@ export const normalizeQueryResult = timeSeries => {
Number(value),
]),
};
// Check result for empty data
normalizedResult.values = normalizedResult.values.filter(series => {
const hasValue = d => !Number.isNaN(d[1]) && (d[1] !== null || d[1] !== undefined);
return series.find(hasValue);
});
} else if (timeSeries.value) {
normalizedResult = {
...timeSeries,
Loading
Loading
@@ -102,21 +45,3 @@ export const normalizeQueryResult = timeSeries => {
 
return normalizedResult;
};
export const normalizeMetrics = metrics => {
const groupedMetrics = groupQueriesByChartInfo(metrics);
return groupedMetrics.map(metric => {
const queries = metric.queries.map(query => ({
...query,
// custom metrics do not require a label, so we should ensure this attribute is defined
label: query.label || metric.y_label,
result: (query.result || []).map(normalizeQueryResult),
}));
return {
...metric,
queries: removeTimeSeriesNoData(queries),
};
});
};
Loading
Loading
@@ -72,10 +72,9 @@ export const ISODateToString = date => dateformat(date, dateFormats.dateTimePick
*/
export const graphDataValidatorForValues = (isValues, graphData) => {
const responseValueKeyName = isValues ? 'value' : 'values';
return (
Array.isArray(graphData.queries) &&
graphData.queries.filter(query => {
Array.isArray(graphData.metrics) &&
graphData.metrics.filter(query => {
if (Array.isArray(query.result)) {
return (
query.result.filter(res => Array.isArray(res[responseValueKeyName])).length ===
Loading
Loading
@@ -83,7 +82,7 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
);
}
return false;
}).length === graphData.queries.length
}).length === graphData.metrics.filter(query => query.result).length
);
};
 
Loading
Loading
@@ -116,6 +115,7 @@ export const generateLinkToChartOptions = chartLink => {
/**
* Tracks snowplow event when user downloads CSV of cluster metric
* @param {String} chart title that will be sent as a property for the event
* @return {Object} config object for event tracking
*/
export const downloadCSVOptions = title => {
const isCLusterHealthBoard = isClusterHealthBoard();
Loading
Loading
@@ -131,7 +131,19 @@ export const downloadCSVOptions = title => {
};
 
/**
* This function validates the graph data contains exactly 3 queries plus
* Generate options for snowplow to track adding a new metric via the dashboard
* custom metric modal
* @return {Object} config object for event tracking
*/
export const getAddMetricTrackingOptions = () => ({
category: document.body.dataset.page,
action: 'click_button',
label: 'add_new_metric',
property: 'modal',
});
/**
* This function validates the graph data contains exactly 3 metrics plus
* value validations from graphDataValidatorForValues.
* @param {Object} isValues
* @param {Object} graphData the graph data response from a prometheus request
Loading
Loading
@@ -140,8 +152,8 @@ export const downloadCSVOptions = title => {
export const graphDataValidatorForAnomalyValues = graphData => {
const anomalySeriesCount = 3; // metric, upper, lower
return (
graphData.queries &&
graphData.queries.length === anomalySeriesCount &&
graphData.metrics &&
graphData.metrics.length === anomalySeriesCount &&
graphDataValidatorForValues(false, graphData)
);
};
Loading
Loading
<script>
import { GlPopover, GlButton, GlLink } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import axios from '~/lib/utils/axios_utils';
export default {
components: {
GlPopover,
GlButton,
GlLink,
Icon,
},
props: {
dismissEndpoint: {
type: String,
required: true,
},
featureId: {
type: String,
required: true,
},
},
data() {
return {
showPopover: false,
};
},
mounted() {
setTimeout(() => {
this.showPopover = true;
}, 2000);
},
methods: {
onDismiss() {
this.showPopover = false;
axios.post(this.dismissEndpoint, {
feature_name: this.featureId,
});
},
},
};
</script>
<template>
<gl-popover target="#diffs-tab" placement="bottom" :show="showPopover">
<p class="mb-2">
{{
__(
'Now you can access the merge request navigation tabs at the top, where they’re easier to find.',
)
}}
</p>
<p>
<gl-link href="https://gitlab.com/gitlab-org/gitlab/issues/36125" target="_blank">
{{ __('More information and share feedback') }}
<icon name="external-link" :size="10" />
</gl-link>
</p>
<gl-button variant="primary" size="sm" @click="onDismiss">
{{ __('Got it') }}
</gl-button>
</gl-popover>
</template>
import Vue from 'vue';
import Popover from './components/popover.vue';
export default el =>
new Vue({
el,
render(createElement) {
return createElement(Popover, {
props: { dismissEndpoint: el.dataset.dismissEndpoint, featureId: el.dataset.featureId },
});
},
});
import initRegistryImages from '~/registry';
import initRegistryImages from '~/registry/list';
 
document.addEventListener('DOMContentLoaded', initRegistryImages);
Loading
Loading
@@ -6,6 +6,7 @@ import howToMerge from '~/how_to_merge';
import initPipelines from '~/commit/pipelines/pipelines_bundle';
import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle';
import initSourcegraph from '~/sourcegraph';
import initPopover from '~/mr_tabs_popover';
import initWidget from '../../../vue_merge_request_widget';
 
export default function() {
Loading
Loading
@@ -21,4 +22,10 @@ export default function() {
howToMerge();
initWidget();
initSourcegraph();
const tabHighlightEl = document.querySelector('.js-tabs-feature-highlight');
if (tabHighlightEl) {
initPopover(tabHighlightEl);
}
}
import initRegistryImages from '~/registry/index';
import initRegistryImages from '~/registry/list/index';
 
document.addEventListener('DOMContentLoaded', initRegistryImages);
Loading
Loading
@@ -52,6 +52,11 @@ export default {
header: s__('PerformanceBar|Redis calls'),
keys: ['cmd'],
},
{
metric: 'total',
header: s__('PerformanceBar|Frontend resources'),
keys: ['name', 'size'],
},
],
data() {
return { currentRequestId: '' };
Loading
Loading
/* eslint-disable @gitlab/i18n/no-non-i18n-strings */
import Vue from 'vue';
import axios from '~/lib/utils/axios_utils';
 
Loading
Loading
@@ -53,12 +54,61 @@ export default ({ container }) =>
PerformanceBarService.fetchRequestDetails(this.peekUrl, requestId)
.then(res => {
this.store.addRequestDetails(requestId, res.data);
if (this.requestId === requestId) this.collectFrontendPerformanceMetrics();
})
.catch(() =>
// eslint-disable-next-line no-console
console.warn(`Error getting performance bar results for ${requestId}`),
);
},
collectFrontendPerformanceMetrics() {
if (performance) {
const navigationEntries = performance.getEntriesByType('navigation');
const paintEntries = performance.getEntriesByType('paint');
const resourceEntries = performance.getEntriesByType('resource');
let durationString = '';
if (navigationEntries.length > 0) {
durationString = `BE ${this.formatMs(navigationEntries[0].responseEnd)} / `;
durationString += `FCP ${this.formatMs(paintEntries[1].startTime)} / `;
durationString += `DOM ${this.formatMs(navigationEntries[0].domContentLoadedEventEnd)}`;
}
let newEntries = resourceEntries.map(this.transformResourceEntry);
this.updateFrontendPerformanceMetrics(durationString, newEntries);
if ('PerformanceObserver' in window) {
// We start observing for more incoming timings
const observer = new PerformanceObserver(list => {
newEntries = newEntries.concat(list.getEntries().map(this.transformResourceEntry));
this.updateFrontendPerformanceMetrics(durationString, newEntries);
});
observer.observe({ entryTypes: ['resource'] });
}
}
},
updateFrontendPerformanceMetrics(durationString, requestEntries) {
this.store.setRequestDetailsData(this.requestId, 'total', {
duration: durationString,
calls: requestEntries.length,
details: requestEntries,
});
},
transformResourceEntry(entry) {
const nf = new Intl.NumberFormat();
return {
name: entry.name.replace(document.location.origin, ''),
duration: Math.round(entry.duration),
size: entry.transferSize ? `${nf.format(entry.transferSize)} bytes` : 'cached',
};
},
formatMs(msValue) {
const nf = new Intl.NumberFormat();
return `${nf.format(Math.round(msValue))}ms`;
},
},
render(createElement) {
return createElement('performance-bar-app', {
Loading
Loading
Loading
Loading
@@ -32,6 +32,16 @@ export default class PerformanceBarStore {
return request;
}
 
setRequestDetailsData(requestId, metricKey, requestDetailsData) {
const selectedRequest = this.findRequest(requestId);
if (selectedRequest) {
selectedRequest.details = {
...selectedRequest.details,
[metricKey]: requestDetailsData,
};
}
}
requestsWithDetails() {
return this.requests.filter(request => request.details);
}
Loading
Loading
Loading
Loading
@@ -6,8 +6,8 @@ import Flash from './flash';
const DEFERRED_LINK_CLASS = 'deferred-link';
 
export default class PersistentUserCallout {
constructor(container) {
const { dismissEndpoint, featureId, deferLinks } = container.dataset;
constructor(container, options = container.dataset) {
const { dismissEndpoint, featureId, deferLinks } = options;
this.container = container;
this.dismissEndpoint = dismissEndpoint;
this.featureId = featureId;
Loading
Loading
@@ -53,11 +53,11 @@ export default class PersistentUserCallout {
});
}
 
static factory(container) {
static factory(container, options) {
if (!container) {
return undefined;
}
 
return new PersistentUserCallout(container);
return new PersistentUserCallout(container, options);
}
}
Loading
Loading
@@ -5,7 +5,7 @@ import store from '../stores';
import CollapsibleContainer from './collapsible_container.vue';
import ProjectEmptyState from './project_empty_state.vue';
import GroupEmptyState from './group_empty_state.vue';
import { s__, sprintf } from '../../locale';
import { s__, sprintf } from '~/locale';
 
export default {
name: 'RegistryListApp',
Loading
Loading
Loading
Loading
@@ -31,7 +31,7 @@ export default {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
mixins: [Tracking.mixin({})],
mixins: [Tracking.mixin()],
props: {
repo: {
type: Object,
Loading
Loading
@@ -43,7 +43,6 @@ export default {
isOpen: false,
modalId: `confirm-repo-deletion-modal-${this.repo.id}`,
tracking: {
category: document.body.dataset.page,
label: 'registry_repository_delete',
},
};
Loading
Loading
@@ -67,7 +66,7 @@ export default {
}
},
handleDeleteRepository() {
this.track('confirm_delete', {});
this.track('confirm_delete');
return this.deleteItem(this.repo)
.then(() => {
createFlash(__('This container registry has been scheduled for deletion.'), 'notice');
Loading
Loading
@@ -103,7 +102,7 @@ export default {
:aria-label="s__('ContainerRegistry|Remove repository')"
class="js-remove-repo btn-inverted"
variant="danger"
@click="track('click_button', {})"
@click="track('click_button')"
>
<icon name="remove" />
</gl-button>
Loading
Loading
@@ -132,7 +131,7 @@ export default {
:modal-id="modalId"
ok-variant="danger"
@ok="handleDeleteRepository"
@cancel="track('cancel_delete', {})"
@cancel="track('cancel_delete')"
>
<template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template>
<p
Loading
Loading
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