Update from merge request

This commit is contained in:
root
2025-07-18 16:09:10 +00:00
parent c467ae2b64
commit a34488deee
23 changed files with 180 additions and 234 deletions

View File

@ -1 +1 @@
47335750c521ec469a5074962fb28b48b9dc06a6
bacb63191f1ba77f6be796e72e9b5bff68fde31d

View File

@ -303,7 +303,7 @@ export default {
is-collapsible
:collapsed="isCollapsed"
persist-collapsed-state
class="!gl-mt-5"
class="!gl-mt-5 !gl-overflow-hidden"
:body-class="{ '!gl-m-0 !gl-p-0': data.count || isPreview }"
@collapsed="isCollapsed = true"
@expanded="isCollapsed = false"
@ -323,7 +323,7 @@ export default {
<component :is="previewPresenter.component" v-else-if="previewPresenter && !hasError" />
<div
v-if="data.count && data.nodes.length < data.count"
class="gl-border-t gl-border-section gl-p-3"
class="glql-load-more gl-border-t gl-border-section gl-p-3"
>
<glql-pagination :count="data.nodes.length" :total-count="data.count" />
</div>
@ -331,11 +331,8 @@ export default {
<template v-if="showEmptyState" #empty>
{{ __('No data found for this query.') }}
</template>
<template #footer>
<glql-footnote />
</template>
</crud-component>
<glql-footnote v-if="!isCollapsed" />
</template>
<div v-else-if="hasError" class="markdown-code-block gl-relative">
<pre :class="preClasses"><code>{{ query }}</code></pre>

View File

@ -21,7 +21,7 @@ export default {
</script>
<template>
<div class="gl-flex gl-items-center gl-gap-1 gl-text-sm gl-text-subtle">
<div class="gl-mb-5 gl-mt-2 gl-flex gl-items-center gl-gap-1 gl-text-sm gl-text-subtle">
<gl-icon class="gl-mb-1 gl-mr-1" :size="12" name="tanuki" />
<gl-sprintf :message="__('%{linkStart}Embedded view%{linkEnd} powered by GLQL')">
<template #link="{ content }">

View File

@ -26,20 +26,3 @@ export const PANEL_POPOVER_DELAY = {
};
export const CURSOR_GRABBING_CLASS = '!gl-cursor-grabbing';
export const NEW_DASHBOARD_SLUG = 'new';
export const CATEGORY_SINGLE_STATS = 'singleStats';
export const CATEGORY_TABLES = 'tables';
export const CATEGORY_CHARTS = 'charts';
export const DASHBOARD_STATUS_BETA = 'beta';
export const DASHBOARD_STATUS_EXPERIMENT = 'experiment';
export const DASHBOARD_SCHEMA_VERSION = '2';
export const VISUALIZATION_TYPE_DATA_TABLE = 'DataTable';
export const VISUALIZATION_TYPE_LINE_CHART = 'LineChart';
export const VISUALIZATION_TYPE_COLUMN_CHART = 'ColumnChart';
export const VISUALIZATION_TYPE_SINGLE_STAT = 'SingleStat';
export const EVENT_LABEL_VIEWED_DASHBOARD_DESIGNER = 'user_viewed_dashboard_designer';

View File

@ -8,14 +8,14 @@ import { s__, __ } from '~/locale';
import { InternalEvents } from '~/tracking';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { EVENT_LABEL_VIEWED_DASHBOARD_DESIGNER } from './constants';
import GridstackWrapper from './gridstack_wrapper.vue';
import AvailableVisualizationsDrawer from './dashboard_editor/available_visualizations_drawer.vue';
import { EVENT_LABEL_VIEWED_DASHBOARD_DESIGNER } from 'ee/analytics/analytics_dashboards/constants';
import {
getDashboardConfig,
availableVisualizationsValidator,
createNewVisualizationPanel,
} from './utils';
} from 'ee/analytics/analytics_dashboards/utils';
import GridstackWrapper from './gridstack_wrapper.vue';
import AvailableVisualizationsDrawer from './dashboard_editor/available_visualizations_drawer.vue';
export default {
name: 'CustomizableDashboard',

View File

@ -5,8 +5,12 @@ import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import { DRAWER_Z_INDEX } from '~/lib/utils/constants';
import { s__ } from '~/locale';
import { toggleArrayItem } from '~/lib/utils/array_utility';
import { getVisualizationCategory } from '../utils';
import { CATEGORY_SINGLE_STATS, CATEGORY_CHARTS, CATEGORY_TABLES } from '../constants';
import {
CATEGORY_SINGLE_STATS,
CATEGORY_CHARTS,
CATEGORY_TABLES,
} from 'ee/analytics/analytics_dashboards/constants';
import { getVisualizationCategory } from 'ee/analytics/analytics_dashboards/utils';
export default {
name: 'AvailableVisualizatiosnDrawer',

View File

@ -1,17 +1,4 @@
import isEmpty from 'lodash/isEmpty';
import uniqueId from 'lodash/uniqueId';
import { humanize } from '~/lib/utils/text_utility';
import { cloneWithoutReferences } from '~/lib/utils/common_utils';
import {
DASHBOARD_SCHEMA_VERSION,
VISUALIZATION_TYPE_DATA_TABLE,
VISUALIZATION_TYPE_SINGLE_STAT,
CATEGORY_SINGLE_STATS,
CATEGORY_CHARTS,
CATEGORY_TABLES,
} from './constants';
export const isEmptyPanelData = (visualizationType, data) => {
if (visualizationType === 'SingleStat') {
@ -22,52 +9,6 @@ export const isEmptyPanelData = (visualizationType, data) => {
return isEmpty(data);
};
/**
* Validator for the availableVisualizations property
*/
export const availableVisualizationsValidator = ({ loading, hasError, visualizations }) => {
return (
typeof loading === 'boolean' && typeof hasError === 'boolean' && Array.isArray(visualizations)
);
};
/**
* Get the category key for visualizations by their type. Default is "charts".
*/
export const getVisualizationCategory = (visualization) => {
if (visualization.type === VISUALIZATION_TYPE_SINGLE_STAT) {
return CATEGORY_SINGLE_STATS;
}
if (visualization.type === VISUALIZATION_TYPE_DATA_TABLE) {
return CATEGORY_TABLES;
}
return CATEGORY_CHARTS;
};
export const getUniquePanelId = () => uniqueId('panel-');
/**
* Maps a full hydrated dashboard (including GraphQL __typenames, and full visualization definitions) into a slimmed down version that complies with the dashboard schema definition
*/
export const getDashboardConfig = (hydratedDashboard) => {
const { __typename: dashboardTypename, userDefined, slug, ...dashboardRest } = hydratedDashboard;
return {
...dashboardRest,
version: DASHBOARD_SCHEMA_VERSION,
panels: hydratedDashboard.panels.map((panel) => {
const { __typename: panelTypename, id, ...panelRest } = panel;
const { __typename: visualizationTypename, ...visualizationRest } = panel.visualization;
return {
...panelRest,
queryOverrides: panel.queryOverrides ?? {},
visualization: visualizationRest,
};
}),
};
};
const filterUndefinedValues = (obj) => {
// eslint-disable-next-line no-unused-vars
return Object.fromEntries(Object.entries(obj).filter(([_, value]) => value !== undefined));
@ -98,18 +39,6 @@ export const parsePanelToGridItem = ({
},
});
export const createNewVisualizationPanel = (visualization) => ({
id: getUniquePanelId(),
title: humanize(visualization.slug),
gridAttributes: {
width: 4,
height: 3,
},
queryOverrides: {},
options: {},
visualization: cloneWithoutReferences({ ...visualization, errors: null }),
});
export const dashboardConfigValidator = (config) => {
if (config.panels) {
if (!Array.isArray(config.panels)) return false;

View File

@ -208,6 +208,11 @@
}
}
// Fix border bleed.
.gl-table-shadow:not(:has(+ .glql-load-more)) {
clip-path: inset(0 round 0 0 1px 1px);
}
.gl-table-shadow {
overflow-x: auto;
overflow-y: hidden;

View File

@ -111,9 +111,9 @@ module Mutations
end
end
def find_parent_by_full_path(full_path)
def find_parent_by_full_path(full_path, model = ::Project)
# Note: Group support is added in the EE module. For CE, we only support bulk edit for projects
::Gitlab::Graphql::Loaders::FullPathModelLoader.new(::Project, full_path).find.sync
::Gitlab::Graphql::Loaders::FullPathModelLoader.new(model, full_path).find.sync
end
end
end

View File

@ -277,6 +277,8 @@ module Gitlab
strategy.increment(cache_key, expiry)
end
return false if value.nil?
report_metrics(key, value, threshold_value, peek)
value > threshold_value

View File

@ -17,7 +17,7 @@ module Gitlab
private
def with_redis(&block)
::Gitlab::Redis::RateLimiting.with(&block) # rubocop: disable CodeReuse/ActiveRecord
::Gitlab::Redis::RateLimiting.with_suppressed_errors(&block)
end
end
end

View File

@ -40,11 +40,7 @@ module Gitlab
private
def with(&block)
Gitlab::Redis::RateLimiting.with(&block)
rescue ::Redis::BaseConnectionError
# Do not raise an error if we cannot connect to Redis. If
# Redis::RateLimiting is unavailable it should not take the site down.
nil
Gitlab::Redis::RateLimiting.with_suppressed_errors(&block)
end
end
end

View File

@ -38,15 +38,7 @@ module Gitlab
private
def with(&block)
# rubocop: disable CodeReuse/ActiveRecord
Gitlab::Redis::RateLimiting.with(&block)
# rubocop: enable CodeReuse/ActiveRecord
rescue ::Redis::BaseConnectionError
# Following the example of
# https://github.com/rack/rack-attack/blob/v6.6.1/lib/rack/attack/store_proxy/redis_proxy.rb#L61-L65,
# do not raise an error if we cannot connect to Redis. If
# Redis::RateLimiting is unavailable it should not take the site down.
nil
Gitlab::Redis::RateLimiting.with_suppressed_errors(&block)
end
def namespace(key)

View File

@ -8,6 +8,13 @@ module Gitlab
def config_fallback
Cache
end
# Rescue Redis errors so we do not take the site down when the rate limiting instance is down
def with_suppressed_errors(&block)
with(&block)
rescue ::Redis::BaseError, ::RedisClient::Error => e
Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e)
end
end
end
end

View File

@ -11,7 +11,7 @@ import AvailableVisualizationsDrawer from '~/vue_shared/components/customizable_
import {
EVENT_LABEL_VIEWED_DASHBOARD_DESIGNER,
DASHBOARD_SCHEMA_VERSION,
} from '~/vue_shared/components/customizable_dashboard/constants';
} from 'ee/analytics/analytics_dashboards/constants';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import { stubComponent } from 'helpers/stub_component';
import { trimText } from 'helpers/text_helper';

View File

@ -10,12 +10,10 @@ import {
GRIDSTACK_CELL_HEIGHT,
GRIDSTACK_MIN_ROW,
} from '~/vue_shared/components/customizable_dashboard/constants';
import { createNewVisualizationPanel } from 'ee/analytics/analytics_dashboards/utils';
import { loadCSSFile } from '~/lib/utils/css_utils';
import waitForPromises from 'helpers/wait_for_promises';
import {
parsePanelToGridItem,
createNewVisualizationPanel,
} from '~/vue_shared/components/customizable_dashboard/utils';
import { parsePanelToGridItem } from '~/vue_shared/components/customizable_dashboard/utils';
import { dashboard, builtinDashboard } from './mock_data';
const mockGridSetStatic = jest.fn();

View File

@ -1,4 +1,4 @@
import { getUniquePanelId } from '~/vue_shared/components/customizable_dashboard/utils';
import { getUniquePanelId } from 'ee/analytics/analytics_dashboards/utils';
export const createVisualization = () => ({
version: 1,

View File

@ -1,39 +1,10 @@
import {
isEmptyPanelData,
availableVisualizationsValidator,
getDashboardConfig,
getVisualizationCategory,
parsePanelToGridItem,
createNewVisualizationPanel,
dashboardConfigValidator,
} from '~/vue_shared/components/customizable_dashboard/utils';
import {
CATEGORY_SINGLE_STATS,
CATEGORY_CHARTS,
CATEGORY_TABLES,
DASHBOARD_SCHEMA_VERSION,
} from '~/vue_shared/components/customizable_dashboard/constants';
import { dashboard, mockPanel, createVisualization } from './mock_data';
describe('#createNewVisualizationPanel', () => {
it('returns the expected object', () => {
const visualization = createVisualization();
expect(createNewVisualizationPanel(visualization)).toMatchObject({
visualization: {
...visualization,
errors: null,
},
title: 'Test visualization',
gridAttributes: {
width: 4,
height: 3,
},
options: {},
});
});
});
import { dashboard, mockPanel } from './mock_data';
describe('isEmptyPanelData', () => {
it.each`
@ -51,84 +22,6 @@ describe('isEmptyPanelData', () => {
);
});
describe('availableVisualizationsValidator', () => {
it('returns true when the object contains all properties', () => {
const result = availableVisualizationsValidator({
loading: false,
hasError: false,
visualizations: [],
});
expect(result).toBe(true);
});
it.each([
{ visualizations: [] },
{ hasError: false },
{ loading: true },
{ loading: true, hasError: false },
])('returns false when the object does not contain all properties', (testCase) => {
const result = availableVisualizationsValidator(testCase);
expect(result).toBe(false);
});
});
describe('getDashboardConfig', () => {
it('maps dashboard to expected value', () => {
const result = getDashboardConfig(dashboard);
const visualization = createVisualization();
expect(result).toMatchObject({
id: 'analytics_overview',
version: DASHBOARD_SCHEMA_VERSION,
panels: [
{
gridAttributes: {
height: 3,
width: 3,
},
queryOverrides: {},
title: 'Test A',
visualization,
},
{
gridAttributes: {
height: 4,
width: 2,
},
queryOverrides: {
limit: 200,
},
title: 'Test B',
visualization,
},
],
title: 'Analytics Overview',
status: null,
errors: null,
});
});
['userDefined', 'slug'].forEach((omitted) => {
it(`omits "${omitted}" dashboard property`, () => {
const result = getDashboardConfig(dashboard);
expect(result[omitted]).not.toBeDefined();
});
});
});
describe('getVisualizationCategory', () => {
it.each`
category | type
${CATEGORY_SINGLE_STATS} | ${'SingleStat'}
${CATEGORY_TABLES} | ${'DataTable'}
${CATEGORY_CHARTS} | ${'LineChart'}
${CATEGORY_CHARTS} | ${'FooBar'}
`('returns $category when the visualization type is $type', ({ category, type }) => {
expect(getVisualizationCategory({ type })).toBe(category);
});
});
describe('parsePanelToGridItem', () => {
it('parses all panel configs to GridStack format', () => {
const { gridAttributes, ...rest } = mockPanel;

View File

@ -28,6 +28,21 @@ RSpec.describe Gitlab::ApplicationRateLimiter, :clean_gitlab_redis_rate_limiting
end
describe '.throttled?' do
context 'when redis is unavailable' do
before do
broken_redis = Redis.new(
url: 'redis://127.0.0.0:0',
custom: { instrumentation_class: Gitlab::Redis::RateLimiting.instrumentation_class }
)
allow(Gitlab::Redis::RateLimiting).to receive(:with).and_yield(broken_redis)
allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
end
it 'returns false' do
expect(subject.throttled?(:test_action, scope: [user])).to eq(false)
end
end
context 'when the key is invalid' do
context 'is provided as a Symbol' do
context 'but is not defined in the rate_limits Hash' do

View File

@ -9,6 +9,8 @@ RSpec.describe Gitlab::CircuitBreaker::Store, :clean_gitlab_redis_rate_limiting,
shared_examples 'reliable circuit breaker store method' do
it 'does not raise an error when Redis::BaseConnectionError is encountered' do
allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
allow(Gitlab::Redis::RateLimiting)
.to receive(:with)
.and_raise(Redis::BaseConnectionError)

View File

@ -105,6 +105,7 @@ RSpec.describe Gitlab::RackAttack::Store, :clean_gitlab_redis_rate_limiting, fea
custom: { instrumentation_class: Gitlab::Redis::RateLimiting.instrumentation_class }
)
allow(Gitlab::Redis::RateLimiting).to receive(:with).and_yield(broken_redis)
allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
end
it { expect(subject).to eq(nil) }

View File

@ -4,4 +4,57 @@ require 'spec_helper'
RSpec.describe Gitlab::Redis::RateLimiting do
include_examples "redis_new_instance_shared_examples", 'rate_limiting', Gitlab::Redis::Cache
describe '.with_suppressed_errors' do
subject(:ping) { described_class.with_suppressed_errors(&:ping) }
before do
allow(described_class).to receive(:with).and_yield(redis)
end
context 'when using Redis' do
let(:redis) { described_class.send(:init_redis, { url: 'redis://127.0.0.0:0' }) }
it 'tracks the error and returns nil' do
expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
.with(a_kind_of(::Redis::CannotConnectError))
expect(ping).to be_nil
end
end
context 'when using RedisCluster' do
let(:redis) do
described_class.send(:init_redis, {
nodes: [
{ host: '127.0.0.0', port: 0, db: 1 },
{ host: '127.0.0.0', port: 0, db: 2 },
{ host: '127.0.0.0', port: 0, db: 3 }
]
})
end
it 'tracks the error and returns nil' do
expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
.with(a_kind_of(::Redis::Cluster::InitialSetupError))
expect(ping).to be_nil
end
end
context 'with a RedisClient exception' do
let(:redis) { instance_double(Redis) }
before do
allow(redis).to receive(:ping).and_raise(::RedisClient::ReadTimeoutError)
end
it 'tracks the error and returns nil' do
expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception)
.with(a_kind_of(::RedisClient::ReadTimeoutError))
expect(ping).to be_nil
end
end
end
end

View File

@ -50,9 +50,78 @@ RSpec.shared_examples 'it has loose foreign keys' do
model = create(factory_name) # rubocop: disable Rails/SaveBang
model_id = model.id
puts "## LFK Debug: Starting test for #{table_name} with model_id: #{model_id} ##"
# Check initial state
initial_pending_count = deleted_records.status_pending.count
initial_processed_count = deleted_records.status_processed.count
initial_total_count = deleted_records.count
puts "## LFK Debug: Initial state - Pending: #{initial_pending_count}, Processed: #{initial_processed_count}, Total: #{initial_total_count} ##"
# Check for existing records for this model_id (shouldn't exist)
existing_records = deleted_records.where(primary_key_value: model_id)
puts "## LFK Debug: Existing records for model_id #{model_id}: #{existing_records.count} ##"
expect { model.delete }.to change { deleted_records.count }.by(1)
LooseForeignKeys::ProcessDeletedRecordsService.new(connection: connection).execute
# Check state after deletion
after_delete_pending = deleted_records.where(primary_key_value: model_id).status_pending.count
after_delete_processed = deleted_records.where(primary_key_value: model_id).status_processed.count
total_pending_after_delete = deleted_records.status_pending.count
total_processed_after_delete = deleted_records.status_processed.count
puts "## LFK Debug: After delete - Model #{model_id} Pending: #{after_delete_pending}, Processed: #{after_delete_processed} ##"
puts "## LFK Debug: After delete - Total Pending: #{total_pending_after_delete}, Total Processed: #{total_processed_after_delete} ##"
# Check all pending records across all tables before processing
all_pending_records = []
Gitlab::Database::LooseForeignKeys.definitions_by_table.each_key do |table|
fqtn = "#{connection.current_schema}.#{table}"
table_records = LooseForeignKeys::DeletedRecord.where(fully_qualified_table_name: fqtn).status_pending
all_pending_records.concat(table_records.to_a)
end
puts "## LFK Debug: Total pending records across all tables before processing: #{all_pending_records.count} ##"
puts "## LFK Debug: Tables with pending records: #{all_pending_records.group_by(&:fully_qualified_table_name).transform_values(&:count)} ##"
# Process records and capture stats
start_time = Time.current
service = LooseForeignKeys::ProcessDeletedRecordsService.new(connection: connection)
stats = service.execute
processing_time = Time.current - start_time
puts "## LFK Debug: Processing took #{processing_time} seconds ##"
puts "## LFK Debug: Processing stats: #{stats.inspect} ##"
# Check state after processing
after_process_pending = deleted_records.where(primary_key_value: model_id).status_pending.count
after_process_processed = deleted_records.where(primary_key_value: model_id).status_processed.count
total_pending_after_process = deleted_records.status_pending.count
total_processed_after_process = deleted_records.status_processed.count
puts "## LFK Debug: After processing - Model #{model_id} Pending: #{after_process_pending}, Processed: #{after_process_processed} ##"
puts "## LFK Debug: After processing - Total Pending: #{total_pending_after_process}, Total Processed: #{total_processed_after_process} ##"
# If the test is about to fail, provide additional debugging
if after_process_pending != 0 || after_process_processed != 1
puts "## LFK Debug: TEST FAILURE IMMINENT - Additional debugging ##"
# Check if our specific record exists and its status
our_record = deleted_records.find_by(primary_key_value: model_id)
if our_record
puts "## LFK Debug: Our record exists - Status: #{our_record.status}, Cleanup attempts: #{our_record.cleanup_attempts}, Consume after: #{our_record.consume_after} ##"
puts "## LFK Debug: Our record details: #{our_record.inspect} ##"
else
puts "## LFK Debug: Our record not found in deleted_records table ##"
end
# Check if there are still pending records for our table
table_pending = deleted_records.status_pending
puts "## LFK Debug: Remaining pending records for #{table_name}: #{table_pending.count} ##"
puts "## LFK Debug: Pending record IDs: #{table_pending.pluck(:primary_key_value)} ##" if table_pending.any?
end
expect(deleted_records.where(primary_key_value: model_id).status_pending.count).to eq(0)
expect(deleted_records.where(primary_key_value: model_id).status_processed.count).to eq(1)