diff options
26 files changed, 735 insertions, 172 deletions
diff --git a/app/assets/javascripts/environments/components/kubernetes_tabs.vue b/app/assets/javascripts/environments/components/kubernetes_tabs.vue index 7c699eec412..60b36596ef3 100644 --- a/app/assets/javascripts/environments/components/kubernetes_tabs.vue +++ b/app/assets/javascripts/environments/components/kubernetes_tabs.vue @@ -1,8 +1,9 @@ <script> import { GlTabs, GlTab, GlLoadingIcon, GlBadge, GlTable, GlPagination } from '@gitlab/ui'; import { __, s__ } from '~/locale'; +import { getAge } from '~/kubernetes_dashboard/helpers/k8s_integration_helper'; import k8sServicesQuery from '../graphql/queries/k8s_services.query.graphql'; -import { generateServicePortsString, getServiceAge } from '../helpers/k8s_integration_helper'; +import { generateServicePortsString } from '../helpers/k8s_integration_helper'; import { SERVICES_LIMIT_PER_PAGE } from '../constants'; import KubernetesSummary from './kubernetes_summary.vue'; @@ -62,7 +63,7 @@ export default { clusterIP: service?.spec?.clusterIP, externalIP: service?.spec?.externalIP, ports: generateServicePortsString(service?.spec?.ports), - age: getServiceAge(service?.metadata?.creationTimestamp), + age: getAge(service?.metadata?.creationTimestamp), }; }); }, diff --git a/app/assets/javascripts/environments/graphql/typedefs.graphql b/app/assets/javascripts/environments/graphql/typedefs.graphql index 41f165ad1da..24898c2cadb 100644 --- a/app/assets/javascripts/environments/graphql/typedefs.graphql +++ b/app/assets/javascripts/environments/graphql/typedefs.graphql @@ -66,8 +66,15 @@ type k8sPodStatus { phase: String } +type k8sPodMetadata { + name: String + namespace: String + creationTimestamp: String +} + type LocalK8sPods { status: k8sPodStatus + metadata: k8sPodMetadata } input LocalConfiguration { diff --git a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js index 93a78e21dfb..a67c8b83eb6 100644 --- a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js +++ b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js @@ -1,4 +1,3 @@ -import { differenceInSeconds } from '~/lib/utils/datetime_utility'; import { CLUSTER_AGENT_ERROR_MESSAGES, STATUS_TRUE, STATUS_FALSE } from '../constants'; export function generateServicePortsString(ports) { @@ -12,30 +11,6 @@ export function generateServicePortsString(ports) { .join(', '); } -export function getServiceAge(creationTimestamp) { - if (!creationTimestamp) return ''; - - const timeDifference = differenceInSeconds(new Date(creationTimestamp), new Date()); - - const seconds = Math.floor(timeDifference); - const minutes = Math.floor(seconds / 60) % 60; - const hours = Math.floor(seconds / 60 / 60) % 24; - const days = Math.floor(seconds / 60 / 60 / 24); - - let ageString; - if (days > 0) { - ageString = `${days}d`; - } else if (hours > 0) { - ageString = `${hours}h`; - } else if (minutes > 0) { - ageString = `${minutes}m`; - } else { - ageString = `${seconds}s`; - } - - return ageString; -} - export function getDeploymentsStatuses(items) { const failed = []; const ready = []; diff --git a/app/assets/javascripts/kubernetes_dashboard/components/workload_layout.vue b/app/assets/javascripts/kubernetes_dashboard/components/workload_layout.vue new file mode 100644 index 00000000000..937b9684fa5 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/components/workload_layout.vue @@ -0,0 +1,44 @@ +<script> +import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import WorkloadStats from './workload_stats.vue'; +import WorkloadTable from './workload_table.vue'; + +export default { + components: { + GlLoadingIcon, + GlAlert, + WorkloadStats, + WorkloadTable, + }, + props: { + loading: { + type: Boolean, + default: false, + required: false, + }, + errorMessage: { + type: String, + default: '', + required: false, + }, + stats: { + type: Array, + required: true, + }, + items: { + type: Array, + required: true, + }, + }, +}; +</script> +<template> + <gl-loading-icon v-if="loading" /> + <gl-alert v-else-if="errorMessage" variant="danger" :dismissible="false" class="gl-mb-5"> + {{ errorMessage }} + </gl-alert> + <div v-else> + <workload-stats :stats="stats" /> + <workload-table :items="items" /> + </div> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/components/workload_table.vue b/app/assets/javascripts/kubernetes_dashboard/components/workload_table.vue new file mode 100644 index 00000000000..65cf00a3b85 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/components/workload_table.vue @@ -0,0 +1,77 @@ +<script> +import { GlTable, GlBadge, GlPagination } from '@gitlab/ui'; +import { + WORKLOAD_STATUS_BADGE_VARIANTS, + PAGE_SIZE, + TABLE_HEADING_CLASSES, + DEFAULT_WORKLOAD_TABLE_FIELDS, +} from '../constants'; + +export default { + components: { + GlTable, + GlBadge, + GlPagination, + }, + props: { + items: { + type: Array, + required: true, + }, + fields: { + type: Array, + default: () => DEFAULT_WORKLOAD_TABLE_FIELDS, + required: false, + }, + }, + data() { + return { + currentPage: 1, + }; + }, + computed: { + tableFields() { + return this.fields.map((field) => { + return { + ...field, + thClass: TABLE_HEADING_CLASSES, + sortable: true, + }; + }); + }, + }, + PAGE_SIZE, + WORKLOAD_STATUS_BADGE_VARIANTS, + TABLE_CELL_CLASSES: 'gl-p-2', +}; +</script> + +<template> + <div class="gl-mt-8"> + <gl-table + :items="items" + :fields="tableFields" + :per-page="$options.PAGE_SIZE" + :current-page="currentPage" + stacked="md" + bordered + > + <template #cell(status)="{ item: { status } }"> + <gl-badge + :variant="$options.WORKLOAD_STATUS_BADGE_VARIANTS[status]" + size="sm" + class="gl-ml-2" + >{{ status }}</gl-badge + > + </template> + </gl-table> + + <gl-pagination + v-model="currentPage" + :per-page="$options.PAGE_SIZE" + :total-items="items.length" + align="center" + class="gl-mt-6" + /> + </div> +</template> diff --git a/app/assets/javascripts/kubernetes_dashboard/constants.js b/app/assets/javascripts/kubernetes_dashboard/constants.js index 2aeb2a4c113..cbb89dff1cf 100644 --- a/app/assets/javascripts/kubernetes_dashboard/constants.js +++ b/app/assets/javascripts/kubernetes_dashboard/constants.js @@ -11,3 +11,33 @@ export const STATUS_LABELS = { [PHASE_SUCCEEDED]: s__('KubernetesDashboard|Succeeded'), [PHASE_FAILED]: s__('KubernetesDashboard|Failed'), }; + +export const WORKLOAD_STATUS_BADGE_VARIANTS = { + [PHASE_RUNNING]: 'info', + [PHASE_PENDING]: 'warning', + [PHASE_SUCCEEDED]: 'success', + [PHASE_FAILED]: 'danger', +}; + +export const PAGE_SIZE = 20; + +export const TABLE_HEADING_CLASSES = 'gl-bg-gray-50! gl-font-weight-bold gl-white-space-nowrap'; + +export const DEFAULT_WORKLOAD_TABLE_FIELDS = [ + { + key: 'name', + label: s__('KubernetesDashboard|Name'), + }, + { + key: 'status', + label: s__('KubernetesDashboard|Status'), + }, + { + key: 'namespace', + label: s__('KubernetesDashboard|Namespace'), + }, + { + key: 'age', + label: s__('KubernetesDashboard|Age'), + }, +]; diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/client.js b/app/assets/javascripts/kubernetes_dashboard/graphql/client.js index 0b9de9c2a61..3a6e90640ae 100644 --- a/app/assets/javascripts/kubernetes_dashboard/graphql/client.js +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/client.js @@ -13,6 +13,11 @@ export const apolloProvider = () => { cache.writeQuery({ query: k8sPodsQuery, data: { + metadata: { + name: null, + namespace: null, + creationTimestamp: null, + }, status: { phase: null, }, diff --git a/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_pods.query.graphql b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_pods.query.graphql index 17d264d32ec..0153d5e298f 100644 --- a/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_pods.query.graphql +++ b/app/assets/javascripts/kubernetes_dashboard/graphql/queries/k8s_dashboard_pods.query.graphql @@ -1,5 +1,10 @@ query getK8sDashboardPods($configuration: LocalConfiguration) { k8sPods(configuration: $configuration) @client { + metadata { + name + namespace + creationTimestamp + } status { phase } diff --git a/app/assets/javascripts/kubernetes_dashboard/helpers/k8s_integration_helper.js b/app/assets/javascripts/kubernetes_dashboard/helpers/k8s_integration_helper.js new file mode 100644 index 00000000000..0c6c0907000 --- /dev/null +++ b/app/assets/javascripts/kubernetes_dashboard/helpers/k8s_integration_helper.js @@ -0,0 +1,25 @@ +import { differenceInSeconds } from '~/lib/utils/datetime_utility'; + +export function getAge(creationTimestamp) { + if (!creationTimestamp) return ''; + + const timeDifference = differenceInSeconds(new Date(creationTimestamp), new Date()); + + const seconds = Math.floor(timeDifference); + const minutes = Math.floor(seconds / 60) % 60; + const hours = Math.floor(seconds / 60 / 60) % 24; + const days = Math.floor(seconds / 60 / 60 / 24); + + let ageString; + if (days > 0) { + ageString = `${days}d`; + } else if (hours > 0) { + ageString = `${hours}h`; + } else if (minutes > 0) { + ageString = `${minutes}m`; + } else { + ageString = `${seconds}s`; + } + + return ageString; +} diff --git a/app/assets/javascripts/kubernetes_dashboard/pages/pods_page.vue b/app/assets/javascripts/kubernetes_dashboard/pages/pods_page.vue index 5d2c3252c15..9cd759f335a 100644 --- a/app/assets/javascripts/kubernetes_dashboard/pages/pods_page.vue +++ b/app/assets/javascripts/kubernetes_dashboard/pages/pods_page.vue @@ -1,6 +1,6 @@ <script> -import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; -import WorkloadStats from '../components/workload_stats.vue'; +import { getAge } from '../helpers/k8s_integration_helper'; +import WorkloadLayout from '../components/workload_layout.vue'; import k8sPodsQuery from '../graphql/queries/k8s_dashboard_pods.query.graphql'; import { PHASE_RUNNING, @@ -12,9 +12,7 @@ import { export default { components: { - GlLoadingIcon, - GlAlert, - WorkloadStats, + WorkloadLayout, }, inject: ['configuration'], apollo: { @@ -26,7 +24,16 @@ export default { }; }, update(data) { - return data?.k8sPods || []; + return ( + data?.k8sPods?.map((pod) => { + return { + name: pod.metadata?.name, + namespace: pod.metadata?.namespace, + status: pod.status.phase, + age: getAge(pod.metadata?.creationTimestamp), + }; + }) || [] + ); }, error(err) { this.errorMessage = err?.message; @@ -35,6 +42,7 @@ export default { }, data() { return { + k8sPods: [], errorMessage: '', }; }, @@ -65,7 +73,7 @@ export default { }, methods: { countPodsByPhase(phase) { - const filteredPods = this.k8sPods?.filter((item) => item.status.phase === phase) || []; + const filteredPods = this.k8sPods?.filter((item) => item.status === phase) || []; return filteredPods.length; }, @@ -73,9 +81,10 @@ export default { }; </script> <template> - <gl-loading-icon v-if="loading" /> - <gl-alert v-else-if="errorMessage" variant="danger" :dismissible="false" class="gl-mb-5"> - {{ errorMessage }} - </gl-alert> - <workload-stats v-else :stats="podStats" /> + <workload-layout + :loading="loading" + :error-message="errorMessage" + :stats="podStats" + :items="k8sPods" + /> </template> diff --git a/app/models/bulk_imports/export_upload.rb b/app/models/bulk_imports/export_upload.rb index 00f8e8f1304..0560933ed93 100644 --- a/app/models/bulk_imports/export_upload.rb +++ b/app/models/bulk_imports/export_upload.rb @@ -11,6 +11,14 @@ module BulkImports mount_uploader :export_file, ExportUploader + # This causes CarrierWave v1 and v3 (but not v2) to upload the file to + # object storage *after* the database entry has been committed to the + # database. This avoids idling in a transaction. Similar to `ImportExportUpload`. + if Gitlab::Utils.to_boolean(ENV.fetch('ENABLE_STORE_EXPORT_FILE_AFTER_COMMIT', true)) + skip_callback :save, :after, :store_export_file! + set_callback :commit, :after, :store_export_file! + end + def retrieve_upload(_identifier, paths) Upload.find_by(model: self, path: paths) end diff --git a/app/uploaders/bulk_imports/export_uploader.rb b/app/uploaders/bulk_imports/export_uploader.rb index cd6e599054b..c1b6f1e0924 100644 --- a/app/uploaders/bulk_imports/export_uploader.rb +++ b/app/uploaders/bulk_imports/export_uploader.rb @@ -2,6 +2,6 @@ module BulkImports class ExportUploader < ImportExportUploader - EXTENSION_ALLOWLIST = %w[ndjson.gz].freeze + EXTENSION_ALLOWLIST = %w[ndjson.gz tar.gz gz].freeze end end diff --git a/app/workers/pages/deactivated_deployments_delete_cron_worker.rb b/app/workers/pages/deactivated_deployments_delete_cron_worker.rb index 7ee6327cea7..75905759761 100644 --- a/app/workers/pages/deactivated_deployments_delete_cron_worker.rb +++ b/app/workers/pages/deactivated_deployments_delete_cron_worker.rb @@ -12,6 +12,7 @@ module Pages def perform PagesDeployment.deactivated.each_batch do |deployments| # rubocop: disable Style/SymbolProc + deployments.each { |deployment| deployment.file.remove! } deployments.delete_all end end diff --git a/db/post_migrate/20231122111935_drop_index_web_hook_logs_part_on_web_hook_id.rb b/db/post_migrate/20231122111935_drop_index_web_hook_logs_part_on_web_hook_id.rb new file mode 100644 index 00000000000..70f3cfee78d --- /dev/null +++ b/db/post_migrate/20231122111935_drop_index_web_hook_logs_part_on_web_hook_id.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class DropIndexWebHookLogsPartOnWebHookId < Gitlab::Database::Migration[2.2] + include Gitlab::Database::PartitioningMigrationHelpers + disable_ddl_transaction! + + milestone '16.7' + + INDEX_NAME = :index_web_hook_logs_part_on_web_hook_id + TABLE_NAME = :web_hook_logs + + def up + remove_concurrent_partitioned_index_by_name(TABLE_NAME, INDEX_NAME) + end + + def down + add_concurrent_partitioned_index(TABLE_NAME, :web_hook_id, name: INDEX_NAME) + end +end diff --git a/db/schema_migrations/20231122111935 b/db/schema_migrations/20231122111935 new file mode 100644 index 00000000000..c7032def15b --- /dev/null +++ b/db/schema_migrations/20231122111935 @@ -0,0 +1 @@ +e054bf0a60fa2dae5353a4f5efec786f76e457f6d1dc0f38483170a41c45456b
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 842472d9b9c..eaf9f4cdd14 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -35044,8 +35044,6 @@ CREATE INDEX index_web_hook_logs_on_web_hook_id_and_created_at ON ONLY web_hook_ CREATE INDEX index_web_hook_logs_part_on_created_at_and_web_hook_id ON ONLY web_hook_logs USING btree (created_at, web_hook_id); -CREATE INDEX index_web_hook_logs_part_on_web_hook_id ON ONLY web_hook_logs USING btree (web_hook_id); - CREATE INDEX index_web_hooks_on_group_id ON web_hooks USING btree (group_id) WHERE ((type)::text = 'GroupHook'::text); CREATE INDEX index_web_hooks_on_integration_id ON web_hooks USING btree (integration_id); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 4aa809f2131..b852bda50ba 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -27758,6 +27758,9 @@ msgstr "" msgid "Kubernetes deployment not found" msgstr "" +msgid "KubernetesDashboard|Age" +msgstr "" + msgid "KubernetesDashboard|Agent %{name} ID #%{id}" msgstr "" @@ -27770,6 +27773,12 @@ msgstr "" msgid "KubernetesDashboard|Failed" msgstr "" +msgid "KubernetesDashboard|Name" +msgstr "" + +msgid "KubernetesDashboard|Namespace" +msgstr "" + msgid "KubernetesDashboard|Pending" msgstr "" @@ -27779,6 +27788,9 @@ msgstr "" msgid "KubernetesDashboard|Running" msgstr "" +msgid "KubernetesDashboard|Status" +msgstr "" + msgid "KubernetesDashboard|Succeeded" msgstr "" @@ -32855,6 +32867,9 @@ msgstr "" msgid "Objective" msgstr "" +msgid "ObservabilityMetrics|Error: Failed to load metrics details. Try reloading the page." +msgstr "" + msgid "ObservabilityMetrics|Metrics" msgstr "" diff --git a/spec/frontend/kubernetes_dashboard/components/workload_layout_spec.js b/spec/frontend/kubernetes_dashboard/components/workload_layout_spec.js new file mode 100644 index 00000000000..4f712343604 --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/components/workload_layout_spec.js @@ -0,0 +1,96 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue'; +import WorkloadStats from '~/kubernetes_dashboard/components/workload_stats.vue'; +import WorkloadTable from '~/kubernetes_dashboard/components/workload_table.vue'; +import { mockPodStats, mockPodsTableItems } from '../graphql/mock_data'; + +let wrapper; + +const defaultProps = { + stats: mockPodStats, + items: mockPodsTableItems, +}; + +const createWrapper = (propsData = {}) => { + wrapper = shallowMount(WorkloadLayout, { + propsData: { + ...defaultProps, + ...propsData, + }, + }); +}; + +const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); +const findErrorAlert = () => wrapper.findComponent(GlAlert); +const findWorkloadStats = () => wrapper.findComponent(WorkloadStats); +const findWorkloadTable = () => wrapper.findComponent(WorkloadTable); + +describe('Workload layout component', () => { + describe('when loading', () => { + beforeEach(() => { + createWrapper({ loading: true, errorMessage: 'error' }); + }); + + it('renders a loading icon', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it("doesn't render an error message", () => { + expect(findErrorAlert().exists()).toBe(false); + }); + + it("doesn't render workload stats", () => { + expect(findWorkloadStats().exists()).toBe(false); + }); + + it("doesn't render workload table", () => { + expect(findWorkloadTable().exists()).toBe(false); + }); + }); + + describe('when received an error', () => { + beforeEach(() => { + createWrapper({ errorMessage: 'error' }); + }); + + it("doesn't render a loading icon", () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders an error alert with the correct message and props', () => { + expect(findErrorAlert().text()).toBe('error'); + expect(findErrorAlert().props()).toMatchObject({ variant: 'danger', dismissible: false }); + }); + + it("doesn't render workload stats", () => { + expect(findWorkloadStats().exists()).toBe(false); + }); + + it("doesn't render workload table", () => { + expect(findWorkloadTable().exists()).toBe(false); + }); + }); + + describe('when received the data', () => { + beforeEach(() => { + createWrapper(); + }); + + it("doesn't render a loading icon", () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it("doesn't render an error message", () => { + expect(findErrorAlert().exists()).toBe(false); + }); + + it('renders workload-stats component with the correct props', () => { + expect(findWorkloadStats().props('stats')).toBe(mockPodStats); + }); + + it('renders workload-table component with the correct props', () => { + expect(findWorkloadTable().props('items')).toBe(mockPodsTableItems); + }); + }); +}); diff --git a/spec/frontend/kubernetes_dashboard/components/workload_table_spec.js b/spec/frontend/kubernetes_dashboard/components/workload_table_spec.js new file mode 100644 index 00000000000..cb16c537597 --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/components/workload_table_spec.js @@ -0,0 +1,120 @@ +import { mount } from '@vue/test-utils'; +import { GlTable, GlBadge, GlPagination } from '@gitlab/ui'; +import WorkloadTable from '~/kubernetes_dashboard/components/workload_table.vue'; +import { TABLE_HEADING_CLASSES, PAGE_SIZE } from '~/kubernetes_dashboard/constants'; +import { mockPodsTableItems } from '../graphql/mock_data'; + +let wrapper; + +const createWrapper = (propsData = {}) => { + wrapper = mount(WorkloadTable, { + propsData, + }); +}; + +const findTable = () => wrapper.findComponent(GlTable); +const findAllRows = () => findTable().find('tbody').findAll('tr'); +const findRow = (at) => findAllRows().at(at); +const findAllBadges = () => wrapper.findAllComponents(GlBadge); +const findBadge = (at) => findAllBadges().at(at); +const findPagination = () => wrapper.findComponent(GlPagination); + +describe('Workload table component', () => { + it('renders GlTable component with the default fields if no fields specified in props', () => { + createWrapper({ items: mockPodsTableItems }); + const defaultFields = [ + { + key: 'name', + label: 'Name', + thClass: TABLE_HEADING_CLASSES, + sortable: true, + }, + { + key: 'status', + label: 'Status', + thClass: TABLE_HEADING_CLASSES, + sortable: true, + }, + { + key: 'namespace', + label: 'Namespace', + thClass: TABLE_HEADING_CLASSES, + sortable: true, + }, + { + key: 'age', + label: 'Age', + thClass: TABLE_HEADING_CLASSES, + sortable: true, + }, + ]; + + expect(findTable().props('fields')).toEqual(defaultFields); + }); + + it('renders GlTable component fields specified in props', () => { + const customFields = [ + { + key: 'field-1', + label: 'Field-1', + thClass: TABLE_HEADING_CLASSES, + sortable: true, + }, + { + key: 'field-2', + label: 'Field-2', + thClass: TABLE_HEADING_CLASSES, + sortable: true, + }, + ]; + createWrapper({ items: mockPodsTableItems, fields: customFields }); + + expect(findTable().props('fields')).toEqual(customFields); + }); + + describe('table rows', () => { + beforeEach(() => { + createWrapper({ items: mockPodsTableItems }); + }); + + it('displays the correct number of rows', () => { + expect(findAllRows()).toHaveLength(mockPodsTableItems.length); + }); + + it('renders correct data for each row', () => { + mockPodsTableItems.forEach((data, index) => { + expect(findRow(index).text()).toContain(data.name); + expect(findRow(index).text()).toContain(data.namespace); + expect(findRow(index).text()).toContain(data.status); + expect(findRow(index).text()).toContain(data.age); + }); + }); + + it('renders a badge for the status', () => { + expect(findAllBadges()).toHaveLength(mockPodsTableItems.length); + }); + + it.each` + status | variant | index + ${'Running'} | ${'info'} | ${0} + ${'Running'} | ${'info'} | ${1} + ${'Pending'} | ${'warning'} | ${2} + ${'Succeeded'} | ${'success'} | ${3} + ${'Failed'} | ${'danger'} | ${4} + ${'Failed'} | ${'danger'} | ${5} + `( + 'renders "$variant" badge for status "$status" at index "$index"', + ({ status, variant, index }) => { + expect(findBadge(index).text()).toBe(status); + expect(findBadge(index).props('variant')).toBe(variant); + }, + ); + + it('renders pagination', () => { + expect(findPagination().props()).toMatchObject({ + totalItems: mockPodsTableItems.length, + perPage: PAGE_SIZE, + }); + }); + }); +}); diff --git a/spec/frontend/kubernetes_dashboard/graphql/mock_data.js b/spec/frontend/kubernetes_dashboard/graphql/mock_data.js index 9e2f110f505..74dc6b33c80 100644 --- a/spec/frontend/kubernetes_dashboard/graphql/mock_data.js +++ b/spec/frontend/kubernetes_dashboard/graphql/mock_data.js @@ -1,7 +1,35 @@ -const runningPod = { status: { phase: 'Running' } }; -const pendingPod = { status: { phase: 'Pending' } }; -const succeededPod = { status: { phase: 'Succeeded' } }; -const failedPod = { status: { phase: 'Failed' } }; +const runningPod = { + status: { phase: 'Running' }, + metadata: { + name: 'pod-1', + namespace: 'default', + creationTimestamp: '2023-07-31T11:50:17Z', + }, +}; +const pendingPod = { + status: { phase: 'Pending' }, + metadata: { + name: 'pod-2', + namespace: 'new-namespace', + creationTimestamp: '2023-11-21T11:50:59Z', + }, +}; +const succeededPod = { + status: { phase: 'Succeeded' }, + metadata: { + name: 'pod-3', + namespace: 'default', + creationTimestamp: '2023-07-31T11:50:17Z', + }, +}; +const failedPod = { + status: { phase: 'Failed' }, + metadata: { + name: 'pod-4', + namespace: 'default', + creationTimestamp: '2023-11-21T11:50:59Z', + }, +}; export const k8sPodsMock = [runningPod, runningPod, pendingPod, succeededPod, failedPod, failedPod]; @@ -23,3 +51,42 @@ export const mockPodStats = [ value: 2, }, ]; + +export const mockPodsTableItems = [ + { + name: 'pod-1', + namespace: 'default', + status: 'Running', + age: '114d', + }, + { + name: 'pod-1', + namespace: 'default', + status: 'Running', + age: '114d', + }, + { + name: 'pod-2', + namespace: 'new-namespace', + status: 'Pending', + age: '1d', + }, + { + name: 'pod-3', + namespace: 'default', + status: 'Succeeded', + age: '114d', + }, + { + name: 'pod-4', + namespace: 'default', + status: 'Failed', + age: '1d', + }, + { + name: 'pod-4', + namespace: 'default', + status: 'Failed', + age: '1d', + }, +]; diff --git a/spec/frontend/kubernetes_dashboard/helpers/k8s_integraion_helper_spec.js b/spec/frontend/kubernetes_dashboard/helpers/k8s_integraion_helper_spec.js new file mode 100644 index 00000000000..0a61604fb6c --- /dev/null +++ b/spec/frontend/kubernetes_dashboard/helpers/k8s_integraion_helper_spec.js @@ -0,0 +1,22 @@ +import { getAge } from '~/kubernetes_dashboard/helpers/k8s_integration_helper'; +import { useFakeDate } from 'helpers/fake_date'; + +describe('k8s_integration_helper', () => { + describe('getAge', () => { + useFakeDate(2023, 10, 23, 10, 10); + + it.each` + condition | measures | timestamp | expected + ${'timestamp > 1 day'} | ${'days'} | ${'2023-07-31T11:50:59Z'} | ${'114d'} + ${'timestamp = 1 day'} | ${'days'} | ${'2023-11-21T11:50:59Z'} | ${'1d'} + ${'1 day > timestamp > 1 hour'} | ${'hours'} | ${'2023-11-22T11:50:59Z'} | ${'22h'} + ${'timestamp = 1 hour'} | ${'hours'} | ${'2023-11-23T08:50:59Z'} | ${'1h'} + ${'1 hour > timestamp >1 minute'} | ${'minutes'} | ${'2023-11-23T09:50:59Z'} | ${'19m'} + ${'timestamp = 1 minute'} | ${'minutes'} | ${'2023-11-23T10:08:59Z'} | ${'1m'} + ${'1 minute > timestamp'} | ${'seconds'} | ${'2023-11-23T10:09:17Z'} | ${'43s'} + ${'timestamp = 1 second'} | ${'seconds'} | ${'2023-11-23T10:09:59Z'} | ${'1s'} + `('returns age in $measures when $condition', ({ timestamp, expected }) => { + expect(getAge(timestamp)).toBe(expected); + }); + }); +}); diff --git a/spec/frontend/kubernetes_dashboard/pages/pods_page_spec.js b/spec/frontend/kubernetes_dashboard/pages/pods_page_spec.js index ea6fd5c93ec..dad5dd742de 100644 --- a/spec/frontend/kubernetes_dashboard/pages/pods_page_spec.js +++ b/spec/frontend/kubernetes_dashboard/pages/pods_page_spec.js @@ -1,12 +1,12 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMount } from '@vue/test-utils'; -import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import PodsPage from '~/kubernetes_dashboard/pages/pods_page.vue'; -import WorkloadStats from '~/kubernetes_dashboard/components/workload_stats.vue'; -import { k8sPodsMock, mockPodStats } from '../graphql/mock_data'; +import WorkloadLayout from '~/kubernetes_dashboard/components/workload_layout.vue'; +import { useFakeDate } from 'helpers/fake_date'; +import { k8sPodsMock, mockPodStats, mockPodsTableItems } from '../graphql/mock_data'; Vue.use(VueApollo); @@ -20,9 +20,7 @@ describe('Kubernetes dashboard pods page', () => { }, }; - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findWorkloadStats = () => wrapper.findComponent(WorkloadStats); - const findAlert = () => wrapper.findComponent(GlAlert); + const findWorkloadLayout = () => wrapper.findComponent(WorkloadLayout); const createApolloProvider = () => { const mockResolvers = { @@ -42,33 +40,41 @@ describe('Kubernetes dashboard pods page', () => { }; describe('mounted', () => { - it('shows the loading icon', () => { + it('renders WorkloadLayout component', () => { createWrapper(); - expect(findLoadingIcon().exists()).toBe(true); + expect(findWorkloadLayout().exists()).toBe(true); }); - it('hides the loading icon when the list of pods loaded', async () => { + it('sets loading prop for the WorkloadLayout', () => { + createWrapper(); + + expect(findWorkloadLayout().props('loading')).toBe(true); + }); + + it('removes loading prop from the WorkloadLayout when the list of pods loaded', async () => { createWrapper(); await waitForPromises(); - expect(findLoadingIcon().exists()).toBe(false); + expect(findWorkloadLayout().props('loading')).toBe(false); }); }); describe('when gets pods data', () => { - it('renders stats', async () => { + useFakeDate(2023, 10, 23, 10, 10); + + it('sets correct stats object for the WorkloadLayout', async () => { createWrapper(); await waitForPromises(); - expect(findWorkloadStats().exists()).toBe(true); + expect(findWorkloadLayout().props('stats')).toEqual(mockPodStats); }); - it('provides correct data for stats', async () => { + it('sets correct table items object for the WorkloadLayout', async () => { createWrapper(); await waitForPromises(); - expect(findWorkloadStats().props('stats')).toEqual(mockPodStats); + expect(findWorkloadLayout().props('items')).toEqual(mockPodsTableItems); }); }); @@ -89,12 +95,8 @@ describe('Kubernetes dashboard pods page', () => { await waitForPromises(); }); - it("doesn't show pods stats", () => { - expect(findWorkloadStats().exists()).toBe(false); - }); - - it('renders an alert with the error message', () => { - expect(findAlert().text()).toBe(error.message); + it('sets errorMessage prop for the WorkloadLayout', () => { + expect(findWorkloadLayout().props('errorMessage')).toBe(error.message); }); }); }); diff --git a/spec/models/bulk_imports/export_upload_spec.rb b/spec/models/bulk_imports/export_upload_spec.rb index d9ae41af0db..ca45fe73b0e 100644 --- a/spec/models/bulk_imports/export_upload_spec.rb +++ b/spec/models/bulk_imports/export_upload_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe BulkImports::ExportUpload do +RSpec.describe BulkImports::ExportUpload, type: :model, feature_category: :importers do subject { described_class.new(export: create(:bulk_import_export)) } describe 'associations' do @@ -20,4 +20,18 @@ RSpec.describe BulkImports::ExportUpload do expect(subject.public_send(method).url).to eq(url) end + + describe 'ActiveRecord callbacks' do + let(:after_save_callbacks) { described_class._save_callbacks.select { |cb| cb.kind == :after } } + let(:after_commit_callbacks) { described_class._commit_callbacks.select { |cb| cb.kind == :after } } + + def find_callback(callbacks, key) + callbacks.find { |cb| cb.filter == key } + end + + it 'export file is stored in after_commit callback' do + expect(find_callback(after_commit_callbacks, :store_export_file!)).to be_present + expect(find_callback(after_save_callbacks, :store_export_file!)).to be_nil + end + end end diff --git a/spec/support/helpers/database/duplicate_indexes.yml b/spec/support/helpers/database/duplicate_indexes.yml index d8e5d0aa8a0..24e0800d0a6 100644 --- a/spec/support/helpers/database/duplicate_indexes.yml +++ b/spec/support/helpers/database/duplicate_indexes.yml @@ -216,9 +216,6 @@ vulnerability_finding_signatures: vulnerability_flags: index_vulnerability_flags_on_unique_columns: - index_vulnerability_flags_on_vulnerability_occurrence_id -web_hook_logs: - index_web_hook_logs_on_web_hook_id_and_created_at: - - index_web_hook_logs_part_on_web_hook_id work_item_hierarchy_restrictions: index_work_item_hierarchy_restrictions_on_parent_and_child: - index_work_item_hierarchy_restrictions_on_parent_type_id diff --git a/spec/tasks/gitlab/click_house/migration_rake_spec.rb b/spec/tasks/gitlab/click_house/migration_rake_spec.rb index f16207de83c..8ef1fe508f2 100644 --- a/spec/tasks/gitlab/click_house/migration_rake_spec.rb +++ b/spec/tasks/gitlab/click_house/migration_rake_spec.rb @@ -8,9 +8,6 @@ RSpec.describe 'gitlab:clickhouse', click_house: :without_migrations, feature_ca # We don't need to delete data since we don't modify Postgres data self.use_transactional_tests = false - let(:migrations_base_dir) { 'click_house/migrations' } - let(:migrations_dirname) { 'undefined' } - let(:migrations_dir) { expand_fixture_path("#{migrations_base_dir}/#{migrations_dirname}") } let(:verbose) { nil } let(:target_version) { nil } let(:step) { nil } @@ -25,163 +22,188 @@ RSpec.describe 'gitlab:clickhouse', click_house: :without_migrations, feature_ca stub_env('STEP', step.to_s) if step end - describe 'migrate' do - subject(:migration) { run_rake_task('gitlab:clickhouse:migrate') } + context 'with real migrations' do + let(:migrations_dir) { File.expand_path(rails_root_join('db', 'click_house', 'migrate')) } - around do |example| + before do ClickHouse::MigrationSupport::Migrator.migrations_paths = [migrations_dir] + end + + it 'runs migrations and rollbacks' do + expect { run_rake_task('gitlab:clickhouse:migrate') }.to change { active_schema_migrations_count }.from(0) + .and output.to_stdout - example.run + expect { run_rake_task('gitlab:clickhouse:rollback') }.to change { active_schema_migrations_count }.by(-1) + .and output.to_stdout - clear_consts(expand_fixture_path(migrations_base_dir)) + stub_env('VERSION', 0) + expect { run_rake_task('gitlab:clickhouse:rollback') }.to change { active_schema_migrations_count }.to(0) end + end + + context 'with migration fixtures' do + let(:migrations_base_dir) { 'click_house/migrations' } + let(:migrations_dirname) { 'undefined' } + let(:migrations_dir) { expand_fixture_path("#{migrations_base_dir}/#{migrations_dirname}") } - describe 'when creating a table' do - let(:migrations_dirname) { 'plain_table_creation' } + describe 'migrate' do + subject(:migration) { run_rake_task('gitlab:clickhouse:migrate') } - it 'creates a table' do - expect { migration }.to change { active_schema_migrations_count }.from(0).to(1) - .and output.to_stdout + around do |example| + ClickHouse::MigrationSupport::Migrator.migrations_paths = [migrations_dir] - expect(describe_table('some')).to match({ - id: a_hash_including(type: 'UInt64'), - date: a_hash_including(type: 'Date') - }) + example.run + + clear_consts(expand_fixture_path(migrations_base_dir)) end - context 'when VERBOSE is false' do - let(:verbose) { 'false' } + describe 'when creating a table' do + let(:migrations_dirname) { 'plain_table_creation' } - it 'does not write to stdout' do - expect { migration }.not_to output.to_stdout + it 'creates a table' do + expect { migration }.to change { active_schema_migrations_count }.from(0).to(1) + .and output.to_stdout expect(describe_table('some')).to match({ id: a_hash_including(type: 'UInt64'), date: a_hash_including(type: 'Date') }) end - end - end - describe 'when dropping a table' do - let(:migrations_dirname) { 'drop_table' } + context 'when VERBOSE is false' do + let(:verbose) { 'false' } - context 'with VERSION set' do - let(:target_version) { 2 } + it 'does not write to stdout' do + expect { migration }.not_to output.to_stdout - it 'drops table' do - stub_env('VERSION', 1) - run_rake_task('gitlab:clickhouse:migrate') + expect(describe_table('some')).to match({ + id: a_hash_including(type: 'UInt64'), + date: a_hash_including(type: 'Date') + }) + end + end + end - expect(table_names).to include('some') + describe 'when dropping a table' do + let(:migrations_dirname) { 'drop_table' } - stub_env('VERSION', target_version) - migration - expect(table_names).not_to include('some') - end + context 'with VERSION set' do + let(:target_version) { 2 } - context 'with STEP also set' do - let(:step) { 1 } + it 'drops table' do + stub_env('VERSION', 1) + run_rake_task('gitlab:clickhouse:migrate') - it 'ignores STEP and executes both migrations' do - migration + expect(table_names).to include('some') + stub_env('VERSION', target_version) + migration expect(table_names).not_to include('some') end + + context 'with STEP also set' do + let(:step) { 1 } + + it 'ignores STEP and executes both migrations' do + migration + + expect(table_names).not_to include('some') + end + end end - end - context 'with STEP set to 1' do - let(:step) { 1 } + context 'with STEP set to 1' do + let(:step) { 1 } - it 'executes only first step and creates table' do - migration + it 'executes only first step and creates table' do + migration - expect(table_names).to include('some') + expect(table_names).to include('some') + end end - end - context 'with STEP set to 0' do - let(:step) { 0 } + context 'with STEP set to 0' do + let(:step) { 0 } - it 'executes only first step and creates table' do - expect { migration }.to raise_error ArgumentError, 'STEP should be a positive number' + it 'executes only first step and creates table' do + expect { migration }.to raise_error ArgumentError, 'STEP should be a positive number' + end end - end - context 'with STEP set to not-a-number' do - let(:step) { 'NaN' } + context 'with STEP set to not-a-number' do + let(:step) { 'NaN' } - it 'raises an error' do - expect { migration }.to raise_error ArgumentError, 'invalid value for Integer(): "NaN"' + it 'raises an error' do + expect { migration }.to raise_error ArgumentError, 'invalid value for Integer(): "NaN"' + end end - end - context 'with STEP set to empty string' do - let(:step) { '' } + context 'with STEP set to empty string' do + let(:step) { '' } - it 'raises an error' do - expect { migration }.to raise_error ArgumentError, 'invalid value for Integer(): ""' + it 'raises an error' do + expect { migration }.to raise_error ArgumentError, 'invalid value for Integer(): ""' + end end end - end - context 'with VERSION is invalid' do - let(:migrations_dirname) { 'plain_table_creation' } - let(:target_version) { 'invalid' } + context 'with VERSION is invalid' do + let(:migrations_dirname) { 'plain_table_creation' } + let(:target_version) { 'invalid' } - it { expect { migration }.to raise_error RuntimeError, 'Invalid format of target version: `VERSION=invalid`' } + it { expect { migration }.to raise_error RuntimeError, 'Invalid format of target version: `VERSION=invalid`' } + end end - end - describe 'rollback' do - subject(:migration) { run_rake_task('gitlab:clickhouse:rollback') } + describe 'rollback' do + subject(:migration) { run_rake_task('gitlab:clickhouse:rollback') } - let(:migrations_dirname) { 'table_creation_with_down_method' } - - around do |example| - ClickHouse::MigrationSupport::Migrator.migrations_paths = [migrations_dir] - # Ensure we start with all migrations up - schema_migration = ClickHouse::MigrationSupport::SchemaMigration - migrate(ClickHouse::MigrationSupport::MigrationContext.new(migrations_dir, schema_migration), nil) - - example.run - - clear_consts(expand_fixture_path(migrations_base_dir)) - end + let(:migrations_dirname) { 'table_creation_with_down_method' } - context 'with VERSION set' do - context 'when migrating back all the way to 0' do - let(:target_version) { 0 } + around do |example| + ClickHouse::MigrationSupport::Migrator.migrations_paths = [migrations_dir] + # Ensure we start with all migrations up + schema_migration = ClickHouse::MigrationSupport::SchemaMigration + migrate(ClickHouse::MigrationSupport::MigrationContext.new(migrations_dir, schema_migration), nil) - it 'rolls back all migrations' do - expect(table_names).to include('some', 'another') + example.run - migration - expect(table_names).not_to include('some', 'another') - end + clear_consts(expand_fixture_path(migrations_base_dir)) + end - context 'with STEP also set' do - let(:step) { 1 } + context 'with VERSION set' do + context 'when migrating back all the way to 0' do + let(:target_version) { 0 } - it 'ignores STEP and rolls back all migrations' do + it 'rolls back all migrations' do expect(table_names).to include('some', 'another') migration expect(table_names).not_to include('some', 'another') end + + context 'with STEP also set' do + let(:step) { 1 } + + it 'ignores STEP and rolls back all migrations' do + expect(table_names).to include('some', 'another') + + migration + expect(table_names).not_to include('some', 'another') + end + end end end - end - context 'with STEP set to 1' do - let(:step) { 1 } + context 'with STEP set to 1' do + let(:step) { 1 } - it 'executes only first step and drops "another" table' do - run_rake_task('gitlab:clickhouse:rollback') + it 'executes only first step and drops "another" table' do + run_rake_task('gitlab:clickhouse:rollback') - expect(table_names).to include('some') - expect(table_names).not_to include('another') + expect(table_names).to include('some') + expect(table_names).not_to include('another') + end end end end diff --git a/spec/workers/pages/deactivated_deployments_delete_cron_worker_spec.rb b/spec/workers/pages/deactivated_deployments_delete_cron_worker_spec.rb index b4ee1d6b439..e2687e7cd9a 100644 --- a/spec/workers/pages/deactivated_deployments_delete_cron_worker_spec.rb +++ b/spec/workers/pages/deactivated_deployments_delete_cron_worker_spec.rb @@ -5,11 +5,14 @@ require 'spec_helper' RSpec.describe Pages::DeactivatedDeploymentsDeleteCronWorker, feature_category: :pages do subject(:worker) { described_class.new } - it 'deletes all deactivated pages deployments' do - create(:pages_deployment) # active - create(:pages_deployment, deleted_at: 3.minutes.ago) # deactivated - create(:pages_deployment, path_prefix: 'other', deleted_at: 3.minutes.ago) # deactivated + let!(:pages_deployment) { create(:pages_deployment) } + let!(:deactivated_pages_deployment) { create(:pages_deployment, deleted_at: 3.minutes.ago) } + let!(:alt_deactivated_pages_deployment) { create(:pages_deployment, path_prefix: 'other', deleted_at: 3.minutes.ago) } + + it 'deletes all deactivated pages deployments and their files from the filesystem' do + file_paths = [deactivated_pages_deployment.file.path, alt_deactivated_pages_deployment.file.path] expect { worker.perform }.to change { PagesDeployment.count }.by(-2) + .and change { (file_paths.any? { |path| File.exist?(path) }) }.from(true).to(false) end end |