diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js index 28f0bde547e7d5611cd6babfd9379b566047c7cb..c0c2e535abf1c76179bff1c81d850a9c3428a3c3 100644 --- a/app/assets/javascripts/environments/constants.js +++ b/app/assets/javascripts/environments/constants.js @@ -191,3 +191,38 @@ export const KUSTOMIZATIONS_RESOURCE_TYPE = 'kustomizations'; export const KUSTOMIZATION = 'Kustomization'; export const HELM_RELEASE = 'HelmRelease'; + +export const TREE_ITEM_KIND_ICONS = { + [KUSTOMIZATION]: 'overview', +}; + +export const TREE_ITEM_STATUS_ICONS = { + reconciled: { + icon: 'status-success', + class: 'gl-text-green-500', + }, + reconciling: { + icon: 'status-running', + class: 'gl-text-blue-500', + }, + reconcilingWithBadConfig: { + icon: 'status-running', + class: 'gl-text-blue-500', + }, + stalled: { + icon: 'status-paused', + class: 'gl-text-orange-500', + }, + failed: { + icon: 'status-failed', + class: 'gl-text-red-500', + }, + unknown: { + icon: 'status-waiting', + class: 'gl-text-gray-500', + }, + unavailable: { + icon: 'status-waiting', + class: 'gl-text-gray-500', + }, +}; diff --git a/app/assets/javascripts/environments/environment_details/components/kubernetes/kubernetes_status_bar.vue b/app/assets/javascripts/environments/environment_details/components/kubernetes/kubernetes_status_bar.vue index f2c1d0b2bdb41afcfbbc7d5bb08931be74a07edf..f8aa6d55ed7a939863a5b8a714bd67ba72476d0b 100644 --- a/app/assets/javascripts/environments/environment_details/components/kubernetes/kubernetes_status_bar.vue +++ b/app/assets/javascripts/environments/environment_details/components/kubernetes/kubernetes_status_bar.vue @@ -7,13 +7,10 @@ import { CLUSTER_HEALTH_ERROR, HEALTH_BADGES, SYNC_STATUS_BADGES, - STATUS_TRUE, - STATUS_FALSE, - STATUS_UNKNOWN, - REASON_PROGRESSING, HELM_RELEASES_RESOURCE_TYPE, KUSTOMIZATIONS_RESOURCE_TYPE, } from '~/environments/constants'; +import { fluxSyncStatus } from '~/environments/helpers/k8s_integration_helper'; import KubernetesConnectionStatus from '~/environments/environment_details/components/kubernetes/kubernetes_connection_status.vue'; import KubernetesConnectionStatusBadge from '~/environments/environment_details/components/kubernetes/kubernetes_connection_status_badge.vue'; import { @@ -131,35 +128,6 @@ export default { fluxBadgeId() { return `${this.environmentName}-flux-sync-badge`; }, - fluxAnyStalled() { - return this.fluxResourceStatus.find((condition) => { - return condition.status === STATUS_TRUE && condition.type === 'Stalled'; - }); - }, - fluxAnyReconcilingWithBadConfig() { - return this.fluxResourceStatus.find((condition) => { - return ( - condition.status === STATUS_UNKNOWN && - condition.type === 'Ready' && - condition.reason === REASON_PROGRESSING - ); - }); - }, - fluxAnyReconciling() { - return this.fluxResourceStatus.find((condition) => { - return condition.status === STATUS_TRUE && condition.type === 'Reconciling'; - }); - }, - fluxAnyReconciled() { - return this.fluxResourceStatus.find((condition) => { - return condition.status === STATUS_TRUE && condition.type === 'Ready'; - }); - }, - fluxAnyFailed() { - return this.fluxResourceStatus.find((condition) => { - return condition.status === STATUS_FALSE && condition.type === 'Ready'; - }); - }, syncStatusBadge() { if (!this.fluxResourceStatus.length && this.fluxApiError) { return { ...SYNC_STATUS_BADGES.unavailable, popoverText: this.fluxApiError }; @@ -167,25 +135,32 @@ export default { if (!this.fluxResourceStatus.length) { return SYNC_STATUS_BADGES.unavailable; } - if (this.fluxAnyFailed) { - return { ...SYNC_STATUS_BADGES.failed, popoverText: this.fluxAnyFailed.message }; - } - if (this.fluxAnyStalled) { - return { ...SYNC_STATUS_BADGES.stalled, popoverText: this.fluxAnyStalled.message }; - } - if (this.fluxAnyReconcilingWithBadConfig) { - return { - ...SYNC_STATUS_BADGES.reconciling, - popoverText: this.fluxAnyReconcilingWithBadConfig.message, - }; - } - if (this.fluxAnyReconciling) { - return SYNC_STATUS_BADGES.reconciling; - } - if (this.fluxAnyReconciled) { - return SYNC_STATUS_BADGES.reconciled; + + const fluxStatus = fluxSyncStatus(this.fluxResourceStatus); + + switch (fluxStatus.status) { + case 'failed': + return { + ...SYNC_STATUS_BADGES.failed, + popoverText: fluxStatus.message, + }; + case 'stalled': + return { + ...SYNC_STATUS_BADGES.stalled, + popoverText: fluxStatus.message, + }; + case 'reconcilingWithBadConfig': + return { + ...SYNC_STATUS_BADGES.reconciling, + popoverText: fluxStatus.message, + }; + case 'reconciling': + return SYNC_STATUS_BADGES.reconciling; + case 'reconciled': + return SYNC_STATUS_BADGES.reconciled; + default: + return SYNC_STATUS_BADGES.unknown; } - return SYNC_STATUS_BADGES.unknown; }, isFluxConnectionStatus() { return Boolean(this.fluxConnectionParams.resourceType); diff --git a/app/assets/javascripts/environments/environment_details/components/kubernetes/kubernetes_summary.vue b/app/assets/javascripts/environments/environment_details/components/kubernetes/kubernetes_summary.vue index 188656afe3ca3cfe5fdf290c1832f13c56efc54b..a40ddfac150ccbf2c7be6ce94037d68c106d236a 100644 --- a/app/assets/javascripts/environments/environment_details/components/kubernetes/kubernetes_summary.vue +++ b/app/assets/javascripts/environments/environment_details/components/kubernetes/kubernetes_summary.vue @@ -2,10 +2,13 @@ import { isEmpty } from 'lodash'; import { GlTab } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { fluxSyncStatus } from '~/environments/helpers/k8s_integration_helper'; +import KubernetesTreeItem from './kubernetes_tree_item.vue'; export default { components: { GlTab, + KubernetesTreeItem, }, i18n: { summaryTitle: s__('Environment|Summary'), @@ -21,6 +24,11 @@ export default { hasFluxKustomization() { return !isEmpty(this.fluxKustomization); }, + fluxKustomizationStatus() { + if (!this.fluxKustomization.conditions?.length) return ''; + + return fluxSyncStatus(this.fluxKustomization.conditions).status; + }, }, }; </script> @@ -28,8 +36,11 @@ export default { <gl-tab :title="$options.i18n.summaryTitle"> <p class="gl-mt-3 gl-text-secondary">{{ $options.i18n.treeView }}</p> - <p v-if="hasFluxKustomization"> - {{ fluxKustomization.kind }}: {{ fluxKustomization.metadata.name }} - </p></gl-tab - > + <kubernetes-tree-item + v-if="hasFluxKustomization" + :kind="fluxKustomization.kind" + :name="fluxKustomization.metadata.name" + :status="fluxKustomizationStatus" + /> + </gl-tab> </template> diff --git a/app/assets/javascripts/environments/environment_details/components/kubernetes/kubernetes_tree_item.vue b/app/assets/javascripts/environments/environment_details/components/kubernetes/kubernetes_tree_item.vue new file mode 100644 index 0000000000000000000000000000000000000000..aee4cd52f66b4d8255fc47ab01a1e81d287b383c --- /dev/null +++ b/app/assets/javascripts/environments/environment_details/components/kubernetes/kubernetes_tree_item.vue @@ -0,0 +1,50 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { TREE_ITEM_KIND_ICONS, TREE_ITEM_STATUS_ICONS } from '~/environments/constants'; + +export default { + components: { + GlIcon, + }, + props: { + kind: { + required: true, + type: String, + }, + name: { + required: true, + type: String, + }, + status: { + required: false, + type: String, + default: '', + }, + }, + computed: { + statusBadge() { + return TREE_ITEM_STATUS_ICONS[this.status] || TREE_ITEM_STATUS_ICONS.unknown; + }, + kindIcon() { + return TREE_ITEM_KIND_ICONS[this.kind]; + }, + }, +}; +</script> +<template> + <div + class="gl-border-1 gl-border-solid gl-border-gray-200 gl-rounded-base gl-p-3 gl-bg-white gl-flex gl-w-28" + > + <gl-icon :name="kindIcon" data-testid="resource-kind-icon" /> + <div class="gl-ml-4"> + <span class="gl-text-secondary gl-block gl-mb-2">{{ kind }}:</span>{{ name }} + <gl-icon + v-if="status" + :name="statusBadge.icon" + :size="12" + :class="statusBadge.class" + data-testid="resource-status-icon" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js index efd352e64290b2a10ba1067cb8c94392d0c4c6f3..9364c32e50e0296244e62dd01e8076b72b2e57e1 100644 --- a/app/assets/javascripts/environments/helpers/k8s_integration_helper.js +++ b/app/assets/javascripts/environments/helpers/k8s_integration_helper.js @@ -1,5 +1,11 @@ import csrf from '~/lib/utils/csrf'; -import { CLUSTER_AGENT_ERROR_MESSAGES } from '../constants'; +import { + CLUSTER_AGENT_ERROR_MESSAGES, + STATUS_TRUE, + STATUS_FALSE, + STATUS_UNKNOWN, + REASON_PROGRESSING, +} from '../constants'; export function humanizeClusterErrors(reason) { const errorReason = String(reason).toLowerCase(); @@ -19,3 +25,55 @@ export function createK8sAccessConfiguration({ kasTunnelUrl, gitlabAgentId }) { credentials: 'include', }; } + +const fluxAnyStalled = (fluxConditions) => { + return fluxConditions.find((condition) => { + return condition.status === STATUS_TRUE && condition.type === 'Stalled'; + }); +}; +const fluxAnyReconcilingWithBadConfig = (fluxConditions) => { + return fluxConditions.find((condition) => { + return ( + condition.status === STATUS_UNKNOWN && + condition.type === 'Ready' && + condition.reason === REASON_PROGRESSING + ); + }); +}; +const fluxAnyReconciling = (fluxConditions) => { + return fluxConditions.find((condition) => { + return condition.status === STATUS_TRUE && condition.type === 'Reconciling'; + }); +}; +const fluxAnyReconciled = (fluxConditions) => { + return fluxConditions.find((condition) => { + return condition.status === STATUS_TRUE && condition.type === 'Ready'; + }); +}; +const fluxAnyFailed = (fluxConditions) => { + return fluxConditions.find((condition) => { + return condition.status === STATUS_FALSE && condition.type === 'Ready'; + }); +}; + +export const fluxSyncStatus = (fluxConditions) => { + if (fluxAnyFailed(fluxConditions)) { + return { status: 'failed', message: fluxAnyFailed(fluxConditions).message }; + } + if (fluxAnyStalled(fluxConditions)) { + return { status: 'stalled', message: fluxAnyStalled(fluxConditions).message }; + } + if (fluxAnyReconcilingWithBadConfig(fluxConditions)) { + return { + status: 'reconcilingWithBadConfig', + message: fluxAnyReconcilingWithBadConfig(fluxConditions).message, + }; + } + if (fluxAnyReconciling(fluxConditions)) { + return { status: 'reconciling' }; + } + if (fluxAnyReconciled(fluxConditions)) { + return { status: 'reconciled' }; + } + return { status: 'unknown' }; +}; diff --git a/spec/frontend/environments/environment_details/components/kubernetes/kubernetes_summary_spec.js b/spec/frontend/environments/environment_details/components/kubernetes/kubernetes_summary_spec.js index 0d2888d82abbdb10635cf6ed42d62ff2aeda6890..8c56af9d60ca2fedf37ad2d18124ae10e2c1aca5 100644 --- a/spec/frontend/environments/environment_details/components/kubernetes/kubernetes_summary_spec.js +++ b/spec/frontend/environments/environment_details/components/kubernetes/kubernetes_summary_spec.js @@ -1,12 +1,14 @@ import { shallowMount } from '@vue/test-utils'; import { GlTab } from '@gitlab/ui'; import KubernetesSummary from '~/environments/environment_details/components/kubernetes/kubernetes_summary.vue'; +import KubernetesTreeItem from '~/environments/environment_details/components/kubernetes/kubernetes_tree_item.vue'; import { fluxKustomization } from '../../../mock_data'; describe('~/environments/environment_details/components/kubernetes/kubernetes_summary.vue', () => { let wrapper; const findTab = () => wrapper.findComponent(GlTab); + const findTreeItem = () => wrapper.findComponent(KubernetesTreeItem); const createWrapper = () => { wrapper = shallowMount(KubernetesSummary, { @@ -27,11 +29,15 @@ describe('~/environments/environment_details/components/kubernetes/kubernetes_su }); it('renders tree view title', () => { - expect(findTab().text()).toContain('Tree view'); + expect(findTab().text()).toBe('Tree view'); }); - it('renders kustomization resource data', () => { - expect(findTab().text()).toContain('Kustomization: my-kustomization'); + it('renders tree item with kustomization resource data', () => { + expect(findTreeItem().props()).toEqual({ + kind: 'Kustomization', + name: 'my-kustomization', + status: 'reconciled', + }); }); }); }); diff --git a/spec/frontend/environments/environment_details/components/kubernetes/kubernetes_tree_item_spec.js b/spec/frontend/environments/environment_details/components/kubernetes/kubernetes_tree_item_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..51186cdb762ae43abc063a5e3eca61c442ce17f1 --- /dev/null +++ b/spec/frontend/environments/environment_details/components/kubernetes/kubernetes_tree_item_spec.js @@ -0,0 +1,52 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import KubernetesTreeItem from '~/environments/environment_details/components/kubernetes/kubernetes_tree_item.vue'; +import { TREE_ITEM_KIND_ICONS, TREE_ITEM_STATUS_ICONS } from '~/environments/constants'; + +describe('~/environments/environment_details/components/kubernetes/kubernetes_tree_item.vue', () => { + let wrapper; + + const kind = 'Kustomization'; + const name = 'my-kustomization'; + + const findKindIcon = () => wrapper.findByTestId('resource-kind-icon'); + const findStatusIcon = () => wrapper.findByTestId('resource-status-icon'); + + const createWrapper = ({ status = 'reconciled' } = {}) => { + wrapper = shallowMountExtended(KubernetesTreeItem, { + propsData: { + kind, + name, + status, + }, + }); + }; + + describe('mounted', () => { + it('renders correct kind icon', () => { + createWrapper(); + + expect(findKindIcon().props('name')).toBe(TREE_ITEM_KIND_ICONS[kind]); + }); + + it('renders correct kind and name', () => { + createWrapper(); + + expect(wrapper.text()).toBe(`${kind}:${name}`); + }); + + it('renders correct status icon when the status is provided', () => { + createWrapper(); + + const iconData = TREE_ITEM_STATUS_ICONS.reconciled; + + expect(findStatusIcon().props('name')).toBe(iconData.icon); + expect(findStatusIcon().attributes('class')).toBe(iconData.class); + }); + + it("doesn't render status icon when the status is not provided", () => { + createWrapper({ status: '' }); + + expect(findStatusIcon().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/environments/helpers/k8s_integration_helper_spec.js b/spec/frontend/environments/helpers/k8s_integration_helper_spec.js index 7db466dd4a509d7a1dc4ccb26d1cacd6ab80876e..7844cf9b5e9c7c6b7c1d698487e59b4e7b0edb71 100644 --- a/spec/frontend/environments/helpers/k8s_integration_helper_spec.js +++ b/spec/frontend/environments/helpers/k8s_integration_helper_spec.js @@ -1,8 +1,15 @@ import { humanizeClusterErrors, createK8sAccessConfiguration, + fluxSyncStatus, } from '~/environments/helpers/k8s_integration_helper'; -import { CLUSTER_AGENT_ERROR_MESSAGES } from '~/environments/constants'; +import { + CLUSTER_AGENT_ERROR_MESSAGES, + STATUS_TRUE, + STATUS_FALSE, + STATUS_UNKNOWN, + REASON_PROGRESSING, +} from '~/environments/constants'; jest.mock('~/lib/utils/csrf', () => ({ headers: { token: 'mock-csrf-token' } })); @@ -53,4 +60,36 @@ describe('k8s_integration_helper', () => { }); }); }); + + describe('fluxSyncStatus', () => { + const message = 'message from Flux'; + let fluxConditions; + + it.each` + status | type | reason | statusText | statusMessage + ${STATUS_TRUE} | ${'Stalled'} | ${''} | ${'stalled'} | ${{ message }} + ${STATUS_TRUE} | ${'Reconciling'} | ${''} | ${'reconciling'} | ${''} + ${STATUS_UNKNOWN} | ${'Ready'} | ${REASON_PROGRESSING} | ${'reconcilingWithBadConfig'} | ${{ message }} + ${STATUS_TRUE} | ${'Ready'} | ${''} | ${'reconciled'} | ${''} + ${STATUS_FALSE} | ${'Ready'} | ${''} | ${'failed'} | ${{ message }} + ${STATUS_UNKNOWN} | ${'Ready'} | ${''} | ${'unknown'} | ${''} + `( + 'renders sync status as $statusText when status is $status, type is $type, and reason is $reason', + ({ status, type, reason, statusText, statusMessage }) => { + fluxConditions = [ + { + status, + type, + reason, + message, + }, + ]; + + expect(fluxSyncStatus(fluxConditions)).toMatchObject({ + status: statusText, + ...statusMessage, + }); + }, + ); + }); });