diff --git a/shell/assets/translations/en-us.yaml b/shell/assets/translations/en-us.yaml index 71f0a332ce2..eeb2138382a 100644 --- a/shell/assets/translations/en-us.yaml +++ b/shell/assets/translations/en-us.yaml @@ -2065,6 +2065,8 @@ clusterIndexPage: etcd: Etcd scheduler: Scheduler controller-manager: Controller Manager + cattle: Cattle + fleet: Fleet certs: label: Certificates diff --git a/shell/pages/c/_cluster/explorer/__tests__/index.test.ts b/shell/pages/c/_cluster/explorer/__tests__/index.test.ts new file mode 100644 index 00000000000..ab14addbb95 --- /dev/null +++ b/shell/pages/c/_cluster/explorer/__tests__/index.test.ts @@ -0,0 +1,113 @@ +import { clone } from '@shell/utils/object'; +import Dashboard from '@shell/pages/c/_cluster/explorer/index.vue'; +import { shallowMount } from '@vue/test-utils'; +import { STATES_ENUM } from '@shell/plugins/dashboard-store/resource-class'; + +describe('page: cluster dashboard', () => { + const mountOptions = { + computed: { monitoringStatus: () => ({ v1: true, v2: true }) }, + stubs: { + 'n-link': true, + LiveDate: true + }, + mocks: { + $store: { + dispatch: jest.fn(), + getters: { + currentCluster: { + id: 'cluster', + metadata: { creationTimestamp: '' }, + status: { provider: 'provider' }, + }, + 'cluster/inError': () => false, + 'cluster/schemaFor': jest.fn(), + 'cluster/all': jest.fn(), + 'i18n/exists': jest.fn(), + 'i18n/t': jest.fn(), + } + } + }, + }; + + describe.each([ + 'etcd', + 'scheduler', + 'controller-manager', + ])('%p component health box', (componentId) => { + it.each([ + [STATES_ENUM.HEALTHY, 'icon-checkmark', '', []], + [STATES_ENUM.HEALTHY, 'icon-checkmark', `foo`, []], + [STATES_ENUM.HEALTHY, 'icon-checkmark', `${ componentId }foo`, [{ status: 'True' }]], + [STATES_ENUM.UNHEALTHY, 'icon-warning', `${ componentId }foo`, [{ status: 'False' }]], + ])('should show %p status', (status, iconClass, name, conditions) => { + const options = clone(mountOptions); + + options.mocks.$store.getters.currentCluster.status = { + provider: 'provider', + componentStatuses: [{ + name, + conditions + }], + }; + + const wrapper = shallowMount(Dashboard, options); + + const box = wrapper.find(`[data-testid="k8s-service-${ componentId }"]`); + const icon = box.find('i'); + + expect(box.element).toBeDefined(); + expect(box.element.classList).toContain(status); + expect(icon.element.classList).toContain(iconClass); + }); + }); + + describe.each([ + ['fleet', true, [ + [STATES_ENUM.IN_PROGRESS, 'icon-spinner', false, false, '', 0, 0], + [STATES_ENUM.UNHEALTHY, 'icon-warning', true, false, [{ status: 'False' }], 0, 0], + [STATES_ENUM.WARNING, 'icon-warning', true, true, [{ status: 'True' }], 0, 0], + [STATES_ENUM.WARNING, 'icon-warning', true, false, [{ status: 'True' }], 0, 0], + [STATES_ENUM.WARNING, 'icon-warning', true, false, [{ status: 'True' }], 0, 1], + [STATES_ENUM.HEALTHY, 'icon-checkmark', true, false, [{ status: 'True' }], 1, 0], + ]], + ['cattle', false, [ + [STATES_ENUM.IN_PROGRESS, 'icon-spinner', false, false, '', 0, 0], + [STATES_ENUM.UNHEALTHY, 'icon-warning', true, false, [{ status: 'False' }], 0, 0], + [STATES_ENUM.UNHEALTHY, 'icon-warning', true, true, [{ status: 'True' }], 0, 0], + [STATES_ENUM.WARNING, 'icon-warning', true, false, [{ status: 'True' }], 0, 0], + [STATES_ENUM.WARNING, 'icon-warning', true, false, [{ status: 'True' }], 0, 1], + [STATES_ENUM.HEALTHY, 'icon-checkmark', true, false, [{ status: 'True' }], 1, 0], + ]] + ])('%p agent health box', (agentId, isLocal, statuses) => { + it.each(statuses)('should show %p status', (status, iconClass, isLoaded, disconnected, conditions, readyReplicas, unavailableReplicas) => { + const options = clone(mountOptions); + + options.mocks.$store.getters.currentCluster.isLocal = isLocal; + + const agent = { + spec: { replicas: 1 }, + status: { + readyReplicas, + unavailableReplicas, + conditions + } + }; + + const wrapper = shallowMount(Dashboard, { + ...options, + data: () => ({ + [agentId]: isLoaded ? agent : null, + disconnected, + canViewAgents: true + }) + }); + + const box = wrapper.find(`[data-testid="k8s-service-${ agentId }"]`); + const icon = box.find('i'); + + expect(box.element).toBeDefined(); + expect(box.element.classList).toContain(status); + expect(icon.element.classList).toContain(iconClass); + }); + }); +}); diff --git a/shell/pages/c/_cluster/explorer/index.vue b/shell/pages/c/_cluster/explorer/index.vue index 196019d9cf9..dcba6f68129 100644 --- a/shell/pages/c/_cluster/explorer/index.vue +++ b/shell/pages/c/_cluster/explorer/index.vue @@ -46,6 +46,7 @@ import { getApplicableExtensionEnhancements } from '@shell/core/plugin-helpers'; import Certificates from '@shell/components/Certificates'; import { NAME as EXPLORER } from '@shell/config/product/explorer'; import TabTitle from '@shell/components/TabTitle'; +import { STATES_ENUM } from '@shell/plugins/dashboard-store/resource-class'; export const RESOURCES = [NAMESPACE, INGRESS, PV, WORKLOAD_TYPES.DEPLOYMENT, WORKLOAD_TYPES.STATEFUL_SET, WORKLOAD_TYPES.JOB, WORKLOAD_TYPES.DAEMON_SET, SERVICE]; @@ -56,7 +57,7 @@ const K8S_METRICS_SUMMARY_URL = '/api/v1/namespaces/cattle-monitoring-system/ser const ETCD_METRICS_DETAIL_URL = '/api/v1/namespaces/cattle-monitoring-system/services/http:rancher-monitoring-grafana:80/proxy/d/rancher-etcd-nodes-1/rancher-etcd-nodes?orgId=1'; const ETCD_METRICS_SUMMARY_URL = '/api/v1/namespaces/cattle-monitoring-system/services/http:rancher-monitoring-grafana:80/proxy/d/rancher-etcd-1/rancher-etcd?orgId=1'; -const COMPONENT_STATUS = [ +const CLUSTER_COMPONENTS = [ 'etcd', 'scheduler', 'controller-manager', @@ -84,7 +85,7 @@ export default { mixins: [metricPoller], - fetch() { + async fetch() { fetchClusterResources(this.$store, NODE); if (this.currentCluster) { @@ -114,6 +115,8 @@ export default { if (this.currentCluster.isLocal && this.$store.getters['management/schemaFor'](MANAGEMENT.NODE)) { this.$store.dispatch('management/findAll', { type: MANAGEMENT.NODE }); } + + await this.loadAgents(); } }, @@ -128,6 +131,10 @@ export default { return { nodeHeaders, constraints: [], + cattle: null, + fleet: null, + canViewAgents: false, + disconnected: false, events: [], nodeMetrics: [], showClusterMetrics: false, @@ -140,6 +147,7 @@ export default { K8S_METRICS_SUMMARY_URL, ETCD_METRICS_DETAIL_URL, ETCD_METRICS_SUMMARY_URL, + STATES_ENUM, clusterCounts, selectedTab: 'cluster-events', extensionCards: getApplicableExtensionEnhancements(this, ExtensionPoint.CARD, CardLocation.CLUSTER_DASHBOARD_CARD, this.$route), @@ -154,6 +162,9 @@ export default { this.$store.dispatch('cluster/forgetType', ENDPOINTS); // Used by AlertTable to get alerts when v2 monitoring is installed this.$store.dispatch('cluster/forgetType', METRIC.NODE); this.$store.dispatch('cluster/forgetType', MANAGEMENT.NODE); + this.$store.dispatch('cluster/forgetType', WORKLOAD_TYPES.DEPLOYMENT); + + clearInterval(this.interval); }, computed: { @@ -212,18 +223,34 @@ export default { return allowedResources.filter((resource) => this.$store.getters['cluster/schemaFor'](resource)); }, - componentServices() { - const status = []; + clusterServices() { + const services = []; - COMPONENT_STATUS.forEach((cs) => { - status.push({ + CLUSTER_COMPONENTS.forEach((cs) => { + services.push({ name: cs, - healthy: this.isComponentStatusHealthy(cs), + status: this.getComponentStatus(cs), labelKey: `clusterIndexPage.sections.componentStatus.${ cs }`, }); }); - return status; + if (this.canViewAgents) { + if (!this.currentCluster.isLocal) { + services.push({ + name: 'cattle', + status: this.getAgentStatus(this.cattle, this.disconnected), + labelKey: 'clusterIndexPage.sections.componentStatus.cattle', + }); + } + + services.push({ + name: 'fleet', + status: this.getAgentStatus(this.fleet), + labelKey: 'clusterIndexPage.sections.componentStatus.fleet', + }); + } + + return services; }, totalCountGaugeInput() { @@ -372,13 +399,35 @@ export default { }, methods: { - // Ported from Ember - isComponentStatusHealthy(field) { + async loadAgents() { + this.canViewAgents = !!this.$store.getters['cluster/schemaFor'](WORKLOAD_TYPES.DEPLOYMENT); + + if (this.canViewAgents) { + if (!this.currentCluster.isLocal) { + this.cattle = await this.$store.dispatch('cluster/find', { + type: WORKLOAD_TYPES.DEPLOYMENT, + id: 'cattle-system/cattle-cluster-agent' + }); + + // Scaling Up/Down cattle deployment causes web sockets disconnection; + this.interval = setInterval(() => { + this.disconnected = !!this.$store.getters['cluster/inError']({ type: NODE }); + }, 1000); + } + + this.fleet = await this.$store.dispatch('cluster/find', { + type: WORKLOAD_TYPES.DEPLOYMENT, + id: `${ this.currentCluster.isLocal ? 'cattle-fleet-local-system' : 'cattle-fleet-system' }/fleet-agent` + }); + } + }, + + getComponentStatus(field) { const matching = (this.currentCluster?.status?.componentStatuses || []).filter((s) => s.name.startsWith(field)); // If there's no matching component status, it's "healthy" if ( !matching.length ) { - return true; + return STATES_ENUM.HEALTHY; } const count = matching.reduce((acc, status) => { @@ -387,7 +436,27 @@ export default { return !conditions ? acc : acc + 1; }, 0); - return count === 0; + if (count > 0) { + return STATES_ENUM.UNHEALTHY; + } + + return STATES_ENUM.HEALTHY; + }, + + getAgentStatus(agent, disconnected = false) { + if (!agent) { + return STATES_ENUM.IN_PROGRESS; + } + + if (disconnected || agent.status.conditions.find((c) => c.status !== 'True')) { + return STATES_ENUM.UNHEALTHY; + } + + if (agent.spec.replicas !== agent.status.readyReplicas || agent.status.unavailableReplicas > 0) { + return STATES_ENUM.WARNING; + } + + return STATES_ENUM.HEALTHY; }, showActions() { @@ -547,22 +616,29 @@ export default { /> -
+
+ -
{{ t(status.labelKey) }}
+
+ {{ t(service.labelKey) }} +
@@ -747,12 +823,17 @@ export default { margin-bottom: 20px; } -.k8s-component-status { +.k8s-service-status { align-items: center; display: inline-flex; border: 1px solid; + border-color: var(--border); margin-top: 20px; + .label { + border-left: 1px solid var(--border); + } + &:not(:last-child) { margin-right: 20px; } @@ -764,10 +845,9 @@ export default { > I { text-align: center; padding: 5px 10px; - border-right: 1px solid var(--border); } - &.k8s-component-status-unhealthy { + &.unhealthy { border-color: var(--error-border); > I { @@ -775,9 +855,13 @@ export default { } } - &.k8s-component-status-healthy { - border-color: var(--border); + &.warning { + > I { + color: var(--warning) + } + } + &.healthy { > I { color: var(--success) }