From 2b06c52e409c7016fe79c7a381b80aad84aaf46b Mon Sep 17 00:00:00 2001 From: Cody Jackson Date: Mon, 4 May 2026 22:28:19 +0000 Subject: [PATCH 1/7] Enable resource cards on workload detail pages Use the Steve summary API (?summary=metadata.state.name) to fetch per-state counts for pods, services, and ingresses without loading full resource collections. Relationship rows (Referred to by / Refers to) read state directly from metadata.relationships. Fixes #16375 --- .../StateCard/__tests__/composables.test.ts | 195 ++++++++- .../Detail/Card/StateCard/composables.ts | 122 ++++++ .../StatusCard/__tests__/StatusCard.test.ts | 61 +++ .../Resource/Detail/Card/StatusCard/index.vue | 73 +++- shell/detail/__tests__/workload.test.ts | 155 +------- shell/detail/workload/index.vue | 57 +-- shell/models/__tests__/workload.test.ts | 376 +++++++++++++++++- shell/models/workload.js | 118 +++++- .../__tests__/resource-class.test.ts | 93 +++++ .../plugins/dashboard-store/resource-class.js | 54 ++- shell/plugins/steve/__tests__/actions.test.ts | 155 ++++++++ shell/plugins/steve/actions.js | 70 ++++ 12 files changed, 1298 insertions(+), 231 deletions(-) create mode 100644 shell/plugins/steve/__tests__/actions.test.ts diff --git a/shell/components/Resource/Detail/Card/StateCard/__tests__/composables.test.ts b/shell/components/Resource/Detail/Card/StateCard/__tests__/composables.test.ts index ee5d1c74967..6632237c506 100644 --- a/shell/components/Resource/Detail/Card/StateCard/__tests__/composables.test.ts +++ b/shell/components/Resource/Detail/Card/StateCard/__tests__/composables.test.ts @@ -1,4 +1,5 @@ -import { useResourceCardRow } from '@shell/components/Resource/Detail/Card/StateCard/composables'; +import { useResourceCardRow, useResourceCardRowFromSummary, useResourceCardRowFromRelationships } from '@shell/components/Resource/Detail/Card/StateCard/composables'; +import type { SummaryResult } from '@shell/components/Resource/Detail/Card/StateCard/composables'; describe('useResourceCardRow', () => { describe('with default keys', () => { @@ -140,3 +141,195 @@ describe('useResourceCardRow', () => { }); }); }); + +describe('useResourceCardRowFromSummary', () => { + it('should return empty props when summaryResult is null', () => { + const result = useResourceCardRowFromSummary('Services', null); + + expect(result.label).toBe('Services'); + expect(result.color).toBeUndefined(); + expect(result.counts).toBeUndefined(); + }); + + it('should return empty props when summary array is empty', () => { + const result = useResourceCardRowFromSummary('Services', { count: 0, summary: [] }); + + expect(result.color).toBeUndefined(); + expect(result.counts).toBeUndefined(); + }); + + it('should return empty props when summary is null', () => { + const result = useResourceCardRowFromSummary('Services', { count: 5, summary: null }); + + expect(result.color).toBeUndefined(); + expect(result.counts).toBeUndefined(); + }); + + it('should return a plain count when no metadata.state.name entry exists', () => { + const summary: SummaryResult = { + count: 3, + summary: [{ property: 'some.other.field', counts: { foo: 3 } }] + }; + + const result = useResourceCardRowFromSummary('Pods', summary); + + expect(result.counts).toStrictEqual([{ label: '', count: 3 }]); + }); + + it('should map state names to colors and display labels', () => { + const summary: SummaryResult = { + count: 5, + summary: [{ property: 'metadata.state.name', counts: { running: 3, error: 2 } }] + }; + + const result = useResourceCardRowFromSummary('Pods', summary); + + expect(result.counts).toHaveLength(2); + expect(result.counts).toContainEqual(expect.objectContaining({ + label: 'running', count: 3, color: 'success' + })); + expect(result.counts).toContainEqual(expect.objectContaining({ + label: 'error', count: 2, color: 'error' + })); + }); + + it('should set the highest alert color as main color', () => { + const summary: SummaryResult = { + count: 4, + summary: [{ property: 'metadata.state.name', counts: { active: 3, error: 1 } }] + }; + + const result = useResourceCardRowFromSummary('Services', summary); + + expect(result.color).toBe('error'); + }); + + it('should sort by alert level then by count', () => { + const summary: SummaryResult = { + count: 6, + summary: [{ + property: 'metadata.state.name', + counts: { + active: 3, error: 1, warning: 2 + } + }] + }; + + const result = useResourceCardRowFromSummary('Pods', summary); + + expect(result.counts![0].color).toBe('error'); + expect(result.counts![1].color).toBe('warning'); + expect(result.counts![2].color).toBe('success'); + }); + + it('should pass the to parameter through', () => { + const to = { hash: '#pods' }; + const result = useResourceCardRowFromSummary('Pods', null, to); + + expect(result.to).toStrictEqual(to); + }); + + it('should handle a single state', () => { + const summary: SummaryResult = { + count: 2, + summary: [{ property: 'metadata.state.name', counts: { active: 2 } }] + }; + + const result = useResourceCardRowFromSummary('Services', summary); + + expect(result.counts).toHaveLength(1); + expect(result.counts![0]).toStrictEqual(expect.objectContaining({ + label: 'active', count: 2, color: 'success' + })); + expect(result.color).toBe('success'); + }); +}); + +describe('useResourceCardRowFromRelationships', () => { + it('should return empty props for empty relationships', () => { + const result = useResourceCardRowFromRelationships('Refers to', []); + + expect(result.label).toBe('Refers to'); + expect(result.color).toBeUndefined(); + expect(result.counts).toBeUndefined(); + }); + + it('should aggregate relationship states', () => { + const rels = [ + { toType: 'configmap', state: 'active' }, + { toType: 'secret', state: 'active' }, + { toType: 'serviceaccount', state: 'error' } + ]; + + const result = useResourceCardRowFromRelationships('Refers to', rels); + + expect(result.counts).toHaveLength(2); + expect(result.counts).toContainEqual(expect.objectContaining({ label: 'active', count: 2 })); + expect(result.counts).toContainEqual(expect.objectContaining({ label: 'error', count: 1 })); + }); + + it('should default missing state to "missing"', () => { + const rels = [ + { toType: 'configmap' }, + { toType: 'secret', state: 'active' } + ]; + + const result = useResourceCardRowFromRelationships('Refers to', rels); + + expect(result.counts).toContainEqual(expect.objectContaining({ + label: 'missing', count: 1, color: 'warning' + })); + expect(result.counts).toContainEqual(expect.objectContaining({ + label: 'active', count: 1, color: 'success' + })); + }); + + it('should set the highest alert color as main color', () => { + const rels = [ + { toType: 'configmap', state: 'active' }, + { toType: 'secret', state: 'error' } + ]; + + const result = useResourceCardRowFromRelationships('Refers to', rels); + + expect(result.color).toBe('error'); + }); + + it('should sort by alert level then by count', () => { + const rels = [ + { toType: 'a', state: 'active' }, + { toType: 'b', state: 'active' }, + { toType: 'c', state: 'active' }, + { toType: 'd', state: 'error' }, + { toType: 'e', state: 'warning' }, + { toType: 'f', state: 'warning' } + ]; + + const result = useResourceCardRowFromRelationships('Refers to', rels); + + expect(result.counts![0].color).toBe('error'); + expect(result.counts![1].color).toBe('warning'); + expect(result.counts![2].color).toBe('success'); + }); + + it('should pass the to parameter through', () => { + const to = { hash: '#related' }; + const result = useResourceCardRowFromRelationships('Refers to', [], to); + + expect(result.to).toStrictEqual(to); + }); + + it('should handle all relationships having no state', () => { + const rels = [ + { toType: 'configmap' }, + { toType: 'secret' } + ]; + + const result = useResourceCardRowFromRelationships('Refers to', rels); + + expect(result.counts).toHaveLength(1); + expect(result.counts![0]).toStrictEqual(expect.objectContaining({ + label: 'missing', count: 2, color: 'warning' + })); + }); +}); diff --git a/shell/components/Resource/Detail/Card/StateCard/composables.ts b/shell/components/Resource/Detail/Card/StateCard/composables.ts index 6856fb54d52..85743d38141 100644 --- a/shell/components/Resource/Detail/Card/StateCard/composables.ts +++ b/shell/components/Resource/Detail/Card/StateCard/composables.ts @@ -6,6 +6,12 @@ import { computed, Ref, toValue } from 'vue'; import { useStore } from 'vuex'; import { Props as StateCardProps } from '@shell/components/Resource/Detail/Card/StateCard/types'; import { RouteLocationRaw } from 'vue-router'; +import { colorForState as colorForStateFn, stateDisplay as stateDisplayFn } from '@shell/plugins/dashboard-store/resource-class'; + +export interface SummaryResult { + count: number; + summary: { property: string; counts: Record }[] | null; +} export function useResourceCardRow(label: string, resources: any[], stateColorKey = 'stateSimpleColor', stateDisplayKey = 'stateDisplay', to?: RouteLocationRaw): ResourceRowProps { const agg: any = {}; @@ -49,6 +55,122 @@ export function useResourceCardRow(label: string, resources: any[], stateColorKe }; } +/** + * Builds a ResourceRowProps from summary API response data. + * The summary API returns state counts as { property: 'metadata.state.name', counts: { running: 2, error: 1 } }. + * This maps those state names to display labels and colors using the same logic as resource models. + */ +export function useResourceCardRowFromSummary(label: string, summaryResult: SummaryResult | null | undefined, to?: RouteLocationRaw): ResourceRowProps { + if (!summaryResult?.summary?.length) { + return { + label, + color: undefined, + counts: undefined, + to + }; + } + + const stateSummary = summaryResult.summary.find((s) => s.property === 'metadata.state.name'); + + if (!stateSummary?.counts) { + return { + label, + color: undefined, + counts: summaryResult.count ? [{ label: '', count: summaryResult.count }] : undefined, + to + }; + } + + interface Tuple extends Count { + color: StateColor; + } + + const tuples: Tuple[] = Object.entries(stateSummary.counts).map(([state, count]) => { + const colorRaw = colorForStateFn(state) as string; + const color = (colorRaw?.replace('text-', '') || 'disabled') as StateColor; + const display = stateDisplayFn(state) as string; + + return { + color, + label: display?.toLowerCase() || state, + count + }; + }); + + tuples.sort((left: Tuple, right: Tuple) => { + if (isHigherAlert(left.color, right.color)) { + return -1; + } + + if (left.color !== right.color) { + return 1; + } + + if (left.count === right.count) { + return 0; + } + + return left.count > right.count ? -1 : 1; + }); + + return { + label, + color: tuples.length ? tuples[0].color : undefined, + counts: tuples.length ? tuples : undefined, + to + }; +} + +/** + * Builds a ResourceRowProps from relationship state data. + * Each relationship entry includes a `state` field (e.g. "active", "error"). + */ +export function useResourceCardRowFromRelationships(label: string, relationships: any[], to?: RouteLocationRaw): ResourceRowProps { + if (!relationships.length) { + return { + label, color: undefined, counts: undefined, to + }; + } + + const agg: Record = {}; + + relationships.forEach((r: any) => { + const state = (r.state || 'missing').toLowerCase(); + const colorRaw = colorForStateFn(state) as string; + const color = (colorRaw?.replace('text-', '') || 'disabled') as StateColor; + const display = (stateDisplayFn(state) as string)?.toLowerCase() || state; + + agg[state] = agg[state] || { + color, label: display, count: 0 + }; + agg[state].count++; + }); + + interface Tuple extends Count { + color: StateColor; + } + const tuples: Tuple[] = Object.values(agg); + + tuples.sort((left: Tuple, right: Tuple) => { + if (isHigherAlert(left.color, right.color)) { + return -1; + } + + if (left.color !== right.color) { + return 1; + } + + return left.count >= right.count ? -1 : 1; + }); + + return { + label, + color: tuples.length ? tuples[0].color : undefined, + counts: tuples.length ? tuples : undefined, + to + }; +} + export interface Pairs { label: string; to?: RouteLocationRaw; diff --git a/shell/components/Resource/Detail/Card/StatusCard/__tests__/StatusCard.test.ts b/shell/components/Resource/Detail/Card/StatusCard/__tests__/StatusCard.test.ts index d740583238a..b9a01feeef6 100644 --- a/shell/components/Resource/Detail/Card/StatusCard/__tests__/StatusCard.test.ts +++ b/shell/components/Resource/Detail/Card/StatusCard/__tests__/StatusCard.test.ts @@ -3,6 +3,7 @@ import StatusCard from '@shell/components/Resource/Detail/Card/StatusCard/index. import StatusBar from '@shell/components/Resource/Detail/StatusBar.vue'; import StatusRow from '@shell/components/Resource/Detail/StatusRow.vue'; import Scaler from '@shell/components/Resource/Detail/Card/Scaler.vue'; +import type { SummaryResult } from '@shell/components/Resource/Detail/Card/StateCard/composables'; describe('component: StatusCard', () => { const mockResource = (stateDisplay: string, stateSimpleColor: string) => ({ @@ -106,4 +107,64 @@ describe('component: StatusCard', () => { expect(wrapper.findComponent(Scaler).exists()).toBe(false); }); }); + + describe('with summaryData', () => { + it('should render StatusBar and StatusRows from summary counts', () => { + const summaryData: SummaryResult = { + count: 5, + summary: [{ property: 'metadata.state.name', counts: { running: 3, error: 2 } }] + }; + + const wrapper = mountCard({ summaryData }); + + expect(wrapper.findComponent(StatusBar).exists()).toBe(true); + expect(wrapper.findAllComponents(StatusRow)).toHaveLength(2); + }); + + it('should use summaryData over resources when both are provided', () => { + const summaryData: SummaryResult = { + count: 4, + summary: [{ property: 'metadata.state.name', counts: { running: 3, completed: 1 } }] + }; + const resources = [ + mockResource('Running', 'text-success'), + ]; + + const wrapper = mountCard({ summaryData, resources }); + + expect(wrapper.findAllComponents(StatusRow)).toHaveLength(2); + }); + + it('should fall back to resources when summaryData has no summary', () => { + const summaryData: SummaryResult = { count: 0, summary: null }; + const resources = [ + mockResource('Running', 'text-success'), + mockResource('Error', 'text-error'), + ]; + + const wrapper = mountCard({ summaryData, resources }); + + expect(wrapper.findAllComponents(StatusRow)).toHaveLength(2); + }); + + it('should show noResourcesMessage when summaryData has no counts', () => { + const summaryData: SummaryResult = { count: 0, summary: [] }; + + const wrapper = mountCard({ summaryData, noResourcesMessage: 'No pods' }); + + expect(wrapper.findComponent(StatusBar).exists()).toBe(false); + expect(wrapper.find('.text-deemphasized').text()).toBe('No pods'); + }); + + it('should render a single state from summary', () => { + const summaryData: SummaryResult = { + count: 2, + summary: [{ property: 'metadata.state.name', counts: { active: 2 } }] + }; + + const wrapper = mountCard({ summaryData }); + + expect(wrapper.findAllComponents(StatusRow)).toHaveLength(1); + }); + }); }); diff --git a/shell/components/Resource/Detail/Card/StatusCard/index.vue b/shell/components/Resource/Detail/Card/StatusCard/index.vue index c5b8cda97ba..674785c30a4 100644 --- a/shell/components/Resource/Detail/Card/StatusCard/index.vue +++ b/shell/components/Resource/Detail/Card/StatusCard/index.vue @@ -8,10 +8,13 @@ import { useI18n } from '@shell/composables/useI18n'; import { StateColor } from '@shell/utils/style'; import { computed } from 'vue'; import { useStore } from 'vuex'; +import { colorForState as colorForStateFn, stateDisplay as stateDisplayFn } from '@shell/plugins/dashboard-store/resource-class'; +import type { SummaryResult } from '@shell/components/Resource/Detail/Card/StateCard/composables'; export interface Props { title: string; resources?: any[]; + summaryData?: SummaryResult | null; showScaling?: boolean; noResourcesMessage?: string; } @@ -23,23 +26,47 @@ const i18n = useI18n(store); const props = withDefaults(defineProps(), { resources: undefined, + summaryData: undefined, showScaling: false, noResourcesMessage: undefined }); const emit = defineEmits(['decrease', 'increase']); +const summaryStateCounts = computed(() => { + const summary = props.summaryData?.summary; + + if (!summary?.length) { + return null; + } + + const entry = summary.find((s) => s.property === 'metadata.state.name'); + + return entry?.counts || null; +}); + const segmentAccumulator = computed(() => { interface Value { count: number; } const accumulator: {[key in StateColor]?: Value} = {}; - - props.resources?.forEach((resource: any) => { - const color: StateColor = resource.stateSimpleColor; - - accumulator[color] = accumulator[color] || { count: 0 }; - accumulator[color].count++; - }); + const stateCounts = summaryStateCounts.value; + + if (stateCounts) { + for (const [state, stateCount] of Object.entries(stateCounts)) { + const colorRaw = colorForStateFn(state) as string; + const color = (colorRaw?.replace('text-', '') || 'disabled') as StateColor; + + accumulator[color] = accumulator[color] || { count: 0 }; + accumulator[color].count += stateCount; + } + } else { + props.resources?.forEach((resource: any) => { + const color: StateColor = resource.stateSimpleColor; + + accumulator[color] = accumulator[color] || { count: 0 }; + accumulator[color].count++; + }); + } return accumulator; }); @@ -50,12 +77,24 @@ const rowAccumulator = computed(() => { color: StateColor; } const accumulator: {[key in string]: Value} = {}; - - props.resources?.forEach((resource: any) => { - accumulator[resource.stateDisplay] = accumulator[resource.stateDisplay] || { count: 0 }; - accumulator[resource.stateDisplay].count++; - accumulator[resource.stateDisplay].color = resource.stateSimpleColor.replace('text-', '') as StateColor; - }); + const stateCounts = summaryStateCounts.value; + + if (stateCounts) { + for (const [state, stateCount] of Object.entries(stateCounts)) { + const display = stateDisplayFn(state) as string; + const colorRaw = colorForStateFn(state) as string; + const color = (colorRaw?.replace('text-', '') || 'disabled') as StateColor; + + accumulator[display] = accumulator[display] || { count: 0, color }; + accumulator[display].count += stateCount; + } + } else { + props.resources?.forEach((resource: any) => { + accumulator[resource.stateDisplay] = accumulator[resource.stateDisplay] || { count: 0, color: 'disabled' as StateColor }; + accumulator[resource.stateDisplay].count++; + accumulator[resource.stateDisplay].color = resource.stateSimpleColor.replace('text-', '') as StateColor; + }); + } return accumulator; }); @@ -64,7 +103,13 @@ const percent = (count: number, total: number) => { return count / total * 100; }; -const count = computed(() => props.resources?.length || 0); +const count = computed(() => { + if (summaryStateCounts.value) { + return props.summaryData?.count || 0; + } + + return props.resources?.length || 0; +}); const segmentColors = computed(() => Object.keys(segmentAccumulator.value) as StateColor[]); const segments = computed(() => segmentColors.value.map((color: StateColor) => ({ diff --git a/shell/detail/__tests__/workload.test.ts b/shell/detail/__tests__/workload.test.ts index fad9720beac..d46a23d9d01 100644 --- a/shell/detail/__tests__/workload.test.ts +++ b/shell/detail/__tests__/workload.test.ts @@ -1,156 +1,7 @@ import Workload from '@shell/detail/workload/index.vue'; -const { findMatchingIngresses } = Workload.methods; - -describe('findMatchingIngresses', () => { - it('should do nothing if ingress schema is not present', () => { - const mockThis = { - ingressSchema: undefined, - matchingIngresses: [], - }; - - findMatchingIngresses.call(mockThis); - expect(mockThis.matchingIngresses).toStrictEqual([]); - }); - - it('should not find any ingresses if there are none', () => { - const mockThis = { - ingressSchema: true, - allIngresses: [], - matchingIngresses: [], - value: { metadata: { namespace: 'test' }, relatedServices: [] } - }; - - findMatchingIngresses.call(mockThis); - expect(mockThis.matchingIngresses).toStrictEqual([]); - }); - - it('should find matching ingresses', () => { - const mockThis = { - ingressSchema: true, - allIngresses: [ - { // matching - metadata: { namespace: 'test' }, - spec: { rules: [{ http: { paths: [{ backend: { service: { name: 'service1' } } }] } }] } - } - ], - matchingIngresses: [], - value: { metadata: { namespace: 'test' }, relatedServices: [{ metadata: { name: 'service1' } }] } - }; - - findMatchingIngresses.call(mockThis); - expect(mockThis.matchingIngresses).toHaveLength(1); - expect(mockThis.matchingIngresses[0]).toStrictEqual(mockThis.allIngresses[0]); - }); - - it('should not match ingresses from other namespaces', () => { - const mockThis = { - ingressSchema: true, - allIngresses: [ - { // not matching - metadata: { namespace: 'other' }, - spec: { rules: [{ http: { paths: [{ backend: { service: { name: 'service1' } } }] } }] } - } - ], - matchingIngresses: [], - value: { metadata: { namespace: 'test' }, relatedServices: [{ metadata: { name: 'service1' } }] } - }; - - findMatchingIngresses.call(mockThis); - expect(mockThis.matchingIngresses).toHaveLength(0); - }); - - it('should not match ingresses pointing to other services', () => { - const mockThis = { - ingressSchema: true, - allIngresses: [ - { // not matching - metadata: { namespace: 'test' }, - spec: { rules: [{ http: { paths: [{ backend: { service: { name: 'service2' } } }] } }] } - } - ], - matchingIngresses: [], - value: { metadata: { namespace: 'test' }, relatedServices: [{ metadata: { name: 'service1' } }] } - }; - - findMatchingIngresses.call(mockThis); - expect(mockThis.matchingIngresses).toHaveLength(0); - }); - - it('should handle ingresses with no rules', () => { - const mockThis = { - ingressSchema: true, - allIngresses: [ - { - metadata: { namespace: 'test' }, - spec: { } - } - ], - matchingIngresses: [], - value: { metadata: { namespace: 'test' }, relatedServices: [{ metadata: { name: 'service1' } }] } - }; - - findMatchingIngresses.call(mockThis); - expect(mockThis.matchingIngresses).toHaveLength(0); - }); - - it('should handle ingress rules with no paths', () => { - const mockThis = { - ingressSchema: true, - allIngresses: [ - { - metadata: { namespace: 'test' }, - spec: { rules: [{ http: {} }] } - } - ], - matchingIngresses: [], - value: { metadata: { namespace: 'test' }, relatedServices: [{ metadata: { name: 'service1' } }] } - }; - - findMatchingIngresses.call(mockThis); - expect(mockThis.matchingIngresses).toHaveLength(0); - }); - - it('should handle ingress paths with no backend service', () => { - const mockThis = { - ingressSchema: true, - allIngresses: [ - { - metadata: { namespace: 'test' }, - spec: { rules: [{ http: { paths: [{ backend: {} }] } }] } - } - ], - matchingIngresses: [], - value: { metadata: { namespace: 'test' }, relatedServices: [{ metadata: { name: 'service1' } }] } - }; - - findMatchingIngresses.call(mockThis); - expect(mockThis.matchingIngresses).toHaveLength(0); - }); - - it('should find one of many ingresses', () => { - const mockThis = { - ingressSchema: true, - allIngresses: [ - { // not matching - metadata: { namespace: 'other' }, - spec: { rules: [{ http: { paths: [{ backend: { service: { name: 'service1' } } }] } }] } - }, - { // matching - metadata: { namespace: 'test' }, - spec: { rules: [{ http: { paths: [{ backend: { service: { name: 'service1' } } }] } }] } - }, - { // not matching - metadata: { namespace: 'test' }, - spec: { rules: [{ http: { paths: [{ backend: { service: { name: 'service2' } } }] } }] } - } - ], - matchingIngresses: [], - value: { metadata: { namespace: 'test' }, relatedServices: [{ metadata: { name: 'service1' } }] } - }; - - findMatchingIngresses.call(mockThis); - expect(mockThis.matchingIngresses).toHaveLength(1); - expect(mockThis.matchingIngresses[0]).toStrictEqual(mockThis.allIngresses[1]); +describe('workload detail page', () => { + it('should not have findMatchingIngresses method (logic moved to workload model)', () => { + expect(Workload.methods?.findMatchingIngresses).toBeUndefined(); }); }); diff --git a/shell/detail/workload/index.vue b/shell/detail/workload/index.vue index 05ee29e2f8d..21b39e00661 100644 --- a/shell/detail/workload/index.vue +++ b/shell/detail/workload/index.vue @@ -36,7 +36,8 @@ export default { nodes: fetchNodesForServiceTargets({ $store: this.$store, inStore: this.$store.getters['currentStore']() - }) + }), + summaries: this.value.fetchSummaries() }; if (this.podSchema) { @@ -71,7 +72,6 @@ export default { this.showProjectMetrics = await allDashboardsExist(this.$store, this.currentCluster.id, [this.WORKLOAD_PROJECT_METRICS_DETAIL_URL, this.WORKLOAD_PROJECT_METRICS_SUMMARY_URL], 'cluster', projectId); } } - this.findMatchingIngresses(); }, async unmounted() { @@ -82,8 +82,6 @@ export default { data() { return { - allIngresses: [], - matchingIngresses: [], WORKLOAD_METRICS_DETAIL_URL, WORKLOAD_METRICS_SUMMARY_URL, POD_PROJECT_METRICS_DETAIL_URL: '', @@ -208,51 +206,6 @@ export default { } }, - methods: { - findMatchingIngresses() { - if (!this.ingressSchema) { - return []; - } - - // Find Ingresses that forward traffic to Services - // that select this workload. - const matchingIngresses = this.allIngresses.filter((ingress) => { - try { - const rules = ingress.spec.rules; - - if (!rules || !Array.isArray(rules)) return false; - - for (let i = 0; i < rules.length; i++) { - const paths = rules[i]?.http?.paths; - - if (!paths || !Array.isArray(paths)) continue; - // For each Ingress, check if any Services that match - // this workload are also target backends for the Ingress. - for (let j = 0; j < paths.length; j++) { - const pathData = paths[j]; - const targetServiceName = pathData?.backend?.service?.name; - - if (!targetServiceName) continue; - - for (let k = 0; k < this.value.relatedServices.length; k++) { - const service = this.value.relatedServices[k]; - const matchingServiceName = service?.metadata?.name; - - if (ingress.metadata?.namespace === this.value.metadata?.namespace && matchingServiceName === targetServiceName) { - return true; - } - } - } - } - } catch (err) { - return false; - } - }); - - this.matchingIngresses = matchingIngresses; - } - }, - watch: { async 'value.jobRelationships.length'(neu, old) { // If there are MORE jobs ensure we go out and fetch them (changes and removals are tracked by watches) @@ -390,7 +343,7 @@ export default { {{ t('workload.detail.cannotViewIngresses') }}

{{ t('workload.detail.cannotFindIngresses') }} @@ -402,8 +355,8 @@ export default { {{ t('workload.detail.ingressListCaption') }}

{ describe('given custom workload keys', () => { @@ -506,4 +506,378 @@ describe('class: Workload', () => { expect(jobsCard).toBeDefined(); }); }); + + describe('getter: matchingIngresses', () => { + const makeWorkload = (services: any[], ingresses: any[], pods: any[] = []) => { + const workload = new Workload({ + type: WORKLOAD_TYPES.DEPLOYMENT, + metadata: { name: 'test', namespace: 'default' }, + spec: {} + }, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: jest.fn(), + rootGetters: { + 'i18n/t': jest.fn(), + 'cluster/all': (type: string) => { + if (type === SERVICE) { + return services; + } + if (type === INGRESS) { + return ingresses; + } + + return []; + } + }, + }); + + Object.defineProperty(workload, 'pods', { get: () => pods }); + + return workload; + }; + + it('should return empty array when no related services', () => { + const workload = makeWorkload([], [ + { + metadata: { namespace: 'default' }, + spec: { rules: [{ http: { paths: [{ backend: { service: { name: 'svc1' } } }] } }] } + } + ]); + + expect(workload.matchingIngresses).toStrictEqual([]); + }); + + it('should find matching ingresses', () => { + const mockPod = { + metadata: { + name: 'pod-1', namespace: 'default', labels: { app: 'my-app' } + } + }; + const mockService = { + metadata: { name: 'svc1', namespace: 'default' }, + spec: { selector: { app: 'my-app' } } + }; + const mockIngress = { + metadata: { namespace: 'default' }, + spec: { rules: [{ http: { paths: [{ backend: { service: { name: 'svc1' } } }] } }] } + }; + + const workload = makeWorkload([mockService], [mockIngress], [mockPod]); + + expect(workload.matchingIngresses).toHaveLength(1); + expect(workload.matchingIngresses[0]).toStrictEqual(mockIngress); + }); + + it('should not match ingresses from other namespaces', () => { + const mockPod = { + metadata: { + name: 'pod-1', namespace: 'default', labels: { app: 'my-app' } + } + }; + const mockService = { + metadata: { name: 'svc1', namespace: 'default' }, + spec: { selector: { app: 'my-app' } } + }; + const mockIngress = { + metadata: { namespace: 'other' }, + spec: { rules: [{ http: { paths: [{ backend: { service: { name: 'svc1' } } }] } }] } + }; + + const workload = makeWorkload([mockService], [mockIngress], [mockPod]); + + expect(workload.matchingIngresses).toHaveLength(0); + }); + + it('should not match ingresses pointing to other services', () => { + const mockPod = { + metadata: { + name: 'pod-1', namespace: 'default', labels: { app: 'my-app' } + } + }; + const mockService = { + metadata: { name: 'svc1', namespace: 'default' }, + spec: { selector: { app: 'my-app' } } + }; + const mockIngress = { + metadata: { namespace: 'default' }, + spec: { rules: [{ http: { paths: [{ backend: { service: { name: 'svc2' } } }] } }] } + }; + + const workload = makeWorkload([mockService], [mockIngress], [mockPod]); + + expect(workload.matchingIngresses).toHaveLength(0); + }); + + it('should handle ingresses with no rules', () => { + const mockPod = { + metadata: { + name: 'pod-1', namespace: 'default', labels: { app: 'my-app' } + } + }; + const mockService = { + metadata: { name: 'svc1', namespace: 'default' }, + spec: { selector: { app: 'my-app' } } + }; + const mockIngress = { + metadata: { namespace: 'default' }, + spec: {} + }; + + const workload = makeWorkload([mockService], [mockIngress], [mockPod]); + + expect(workload.matchingIngresses).toHaveLength(0); + }); + + it('should handle ingress rules with no paths', () => { + const mockPod = { + metadata: { + name: 'pod-1', namespace: 'default', labels: { app: 'my-app' } + } + }; + const mockService = { + metadata: { name: 'svc1', namespace: 'default' }, + spec: { selector: { app: 'my-app' } } + }; + const mockIngress = { + metadata: { namespace: 'default' }, + spec: { rules: [{ http: {} }] } + }; + + const workload = makeWorkload([mockService], [mockIngress], [mockPod]); + + expect(workload.matchingIngresses).toHaveLength(0); + }); + + it('should handle ingress paths with no backend service', () => { + const mockPod = { + metadata: { + name: 'pod-1', namespace: 'default', labels: { app: 'my-app' } + } + }; + const mockService = { + metadata: { name: 'svc1', namespace: 'default' }, + spec: { selector: { app: 'my-app' } } + }; + const mockIngress = { + metadata: { namespace: 'default' }, + spec: { rules: [{ http: { paths: [{ backend: {} }] } }] } + }; + + const workload = makeWorkload([mockService], [mockIngress], [mockPod]); + + expect(workload.matchingIngresses).toHaveLength(0); + }); + + it('should find one of many ingresses', () => { + const mockPod = { + metadata: { + name: 'pod-1', namespace: 'default', labels: { app: 'my-app' } + } + }; + const mockService = { + metadata: { name: 'svc1', namespace: 'default' }, + spec: { selector: { app: 'my-app' } } + }; + const ingresses = [ + { + metadata: { namespace: 'other' }, + spec: { rules: [{ http: { paths: [{ backend: { service: { name: 'svc1' } } }] } }] } + }, + { + metadata: { namespace: 'default' }, + spec: { rules: [{ http: { paths: [{ backend: { service: { name: 'svc1' } } }] } }] } + }, + { + metadata: { namespace: 'default' }, + spec: { rules: [{ http: { paths: [{ backend: { service: { name: 'svc2' } } }] } }] } + } + ]; + + const workload = makeWorkload([mockService], ingresses, [mockPod]); + + expect(workload.matchingIngresses).toHaveLength(1); + expect(workload.matchingIngresses[0]).toStrictEqual(ingresses[1]); + }); + }); + + describe('getter: resourcesCardRows', () => { + it('should include services row when related services exist', () => { + const mockPod = { + metadata: { + name: 'pod-1', namespace: 'default', labels: { app: 'my-app' } + } + }; + const mockService = { + metadata: { name: 'svc1', namespace: 'default' }, + spec: { selector: { app: 'my-app' } }, + stateDisplay: 'Active', + stateSimpleColor: 'success' + }; + + const workload = new Workload({ + type: WORKLOAD_TYPES.DEPLOYMENT, + metadata: { name: 'test', namespace: 'default' }, + spec: {} + }, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: jest.fn(), + rootGetters: { + 'i18n/t': (key: string) => key, + 'cluster/all': (type: string) => { + if (type === SERVICE) { + return [mockService]; + } + + return []; + } + }, + }); + + Object.defineProperty(workload, 'pods', { get: () => [mockPod] }); + + const rows = workload.resourcesCardRows; + const servicesRow = rows.find((r: any) => r.label === 'component.resource.detail.card.resourcesCard.rows.services'); + + expect(servicesRow).toBeDefined(); + }); + + it('should include ingresses row when matching ingresses exist', () => { + const mockPod = { + metadata: { + name: 'pod-1', namespace: 'default', labels: { app: 'my-app' } + } + }; + const mockService = { + metadata: { name: 'svc1', namespace: 'default' }, + spec: { selector: { app: 'my-app' } }, + stateDisplay: 'Active', + stateSimpleColor: 'success' + }; + const mockIngress = { + metadata: { namespace: 'default' }, + spec: { rules: [{ http: { paths: [{ backend: { service: { name: 'svc1' } } }] } }] }, + stateDisplay: 'Active', + stateSimpleColor: 'success' + }; + + const workload = new Workload({ + type: WORKLOAD_TYPES.DEPLOYMENT, + metadata: { name: 'test', namespace: 'default' }, + spec: {} + }, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: jest.fn(), + rootGetters: { + 'i18n/t': (key: string) => key, + 'cluster/all': (type: string) => { + if (type === SERVICE) { + return [mockService]; + } + if (type === INGRESS) { + return [mockIngress]; + } + + return []; + } + }, + }); + + Object.defineProperty(workload, 'pods', { get: () => [mockPod] }); + + const rows = workload.resourcesCardRows; + const ingressesRow = rows.find((r: any) => r.label === 'component.resource.detail.card.resourcesCard.rows.ingresses'); + + expect(ingressesRow).toBeDefined(); + expect(ingressesRow.to).toBe('#ingresses'); + }); + + it('should order services before ingresses', () => { + const mockPod = { + metadata: { + name: 'pod-1', namespace: 'default', labels: { app: 'my-app' } + } + }; + const mockService = { + metadata: { name: 'svc1', namespace: 'default' }, + spec: { selector: { app: 'my-app' } }, + stateDisplay: 'Active', + stateSimpleColor: 'success' + }; + const mockIngress = { + metadata: { namespace: 'default' }, + spec: { rules: [{ http: { paths: [{ backend: { service: { name: 'svc1' } } }] } }] }, + stateDisplay: 'Active', + stateSimpleColor: 'success' + }; + + const workload = new Workload({ + type: WORKLOAD_TYPES.DEPLOYMENT, + metadata: { name: 'test', namespace: 'default' }, + spec: {} + }, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: jest.fn(), + rootGetters: { + 'i18n/t': (key: string) => key, + 'cluster/all': (type: string) => { + if (type === SERVICE) { + return [mockService]; + } + if (type === INGRESS) { + return [mockIngress]; + } + + return []; + } + }, + }); + + Object.defineProperty(workload, 'pods', { get: () => [mockPod] }); + + const rows = workload.resourcesCardRows; + + expect(rows[0].label).toBe('component.resource.detail.card.resourcesCard.rows.services'); + expect(rows[1].label).toBe('component.resource.detail.card.resourcesCard.rows.ingresses'); + }); + + it('should not include ingresses row when no matching ingresses', () => { + const mockPod = { + metadata: { + name: 'pod-1', namespace: 'default', labels: { app: 'my-app' } + } + }; + const mockService = { + metadata: { name: 'svc1', namespace: 'default' }, + spec: { selector: { app: 'my-app' } }, + stateDisplay: 'Active', + stateSimpleColor: 'success' + }; + + const workload = new Workload({ + type: WORKLOAD_TYPES.DEPLOYMENT, + metadata: { name: 'test', namespace: 'default' }, + spec: {} + }, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: jest.fn(), + rootGetters: { + 'i18n/t': (key: string) => key, + 'cluster/all': (type: string) => { + if (type === SERVICE) { + return [mockService]; + } + + return []; + } + }, + }); + + Object.defineProperty(workload, 'pods', { get: () => [mockPod] }); + + const rows = workload.resourcesCardRows; + const ingressesRow = rows.find((r: any) => r.label === 'component.resource.detail.card.resourcesCard.rows.ingresses'); + + expect(ingressesRow).toBeUndefined(); + }); + }); }); diff --git a/shell/models/workload.js b/shell/models/workload.js index bcbc44f620e..49f0a2aa2f3 100644 --- a/shell/models/workload.js +++ b/shell/models/workload.js @@ -1,6 +1,6 @@ import { findBy, insertAt } from '@shell/utils/array'; import { CATTLE_PUBLIC_ENDPOINTS } from '@shell/config/labels-annotations'; -import { WORKLOAD_TYPES, SERVICE, POD } from '@shell/config/types'; +import { WORKLOAD_TYPES, SERVICE, INGRESS, POD } from '@shell/config/types'; import { set } from '@shell/utils/object'; import day from 'dayjs'; import { convertSelectorObj, parse, matches } from '@shell/utils/selector'; @@ -8,8 +8,9 @@ import { SEPARATOR } from '@shell/config/workload'; import WorkloadService from '@shell/models/workload.service'; import { matching } from '@shell/utils/selector-typed'; import { defineAsyncComponent, markRaw } from 'vue'; -import { useResourceCardRow } from '@shell/components/Resource/Detail/Card/StateCard/composables'; +import { useResourceCardRow, useResourceCardRowFromSummary } from '@shell/components/Resource/Detail/Card/StateCard/composables'; import { colorForState as colorForStateFn, stateDisplay as stateDisplayFn } from '@shell/plugins/dashboard-store/resource-class'; +import { PaginationParamFilter } from '@shell/types/store/pagination.types'; export const defaultContainer = { imagePullPolicy: 'Always', @@ -586,6 +587,45 @@ export default class Workload extends WorkloadService { return undefined; } + async fetchSummaries() { + const ns = this.metadata.namespace; + const summaries = {}; + const nsFilter = PaginationParamFilter.createSingleField({ field: 'metadata.namespace', value: ns }); + + const podSelectorStr = this.podSelector; + + if (podSelectorStr) { + const filters = [nsFilter]; + + podSelectorStr.split(',').forEach((part) => { + const eqIdx = part.indexOf('='); + + if (eqIdx > 0) { + const key = part.substring(0, eqIdx).trim(); + const value = part.substring(eqIdx + 1).trim(); + + filters.push(PaginationParamFilter.createSingleField({ field: `metadata.labels.${ key }`, value })); + } + }); + + summaries.pods = await this.$dispatch('fetchSummary', { type: POD, opt: { summaryField: 'metadata.state.name', filters } }); + } + + summaries.services = await this.$dispatch('fetchSummary', { + type: SERVICE, + opt: { summaryField: 'metadata.state.name', filters: [nsFilter] } + }); + + summaries.ingresses = await this.$dispatch('fetchSummary', { + type: INGRESS, + opt: { summaryField: 'metadata.state.name', filters: [nsFilter] } + }); + + set(this, '_summaries', summaries); + + return summaries; + } + async unWatchPods() { return await this.$dispatch('unwatch', { type: POD, all: true }); } @@ -763,11 +803,70 @@ export default class Workload extends WorkloadService { }); } + get matchingIngresses() { + const allIngresses = this.$rootGetters['cluster/all'](INGRESS); + const services = this.relatedServices; + + if (!services.length) { + return []; + } + + return allIngresses.filter((ingress) => { + try { + const rules = ingress.spec?.rules; + + if (!rules || !Array.isArray(rules)) { + return false; + } + + for (const rule of rules) { + const paths = rule?.http?.paths; + + if (!paths || !Array.isArray(paths)) { + continue; + } + + for (const pathData of paths) { + const targetServiceName = pathData?.backend?.service?.name; + + if (!targetServiceName) { + continue; + } + + for (const service of services) { + if (ingress.metadata?.namespace === this.metadata?.namespace && service?.metadata?.name === targetServiceName) { + return true; + } + } + } + } + } catch (err) { + return false; + } + + return false; + }); + } + get resourcesCardRows() { - return [ - useResourceCardRow(this.t('component.resource.detail.card.resourcesCard.rows.services'), this.relatedServices, undefined, undefined, '#services'), - ...this._resourcesCardRows, - ]; + const rows = [...this._resourcesCardRows]; + const services = this.relatedServices || []; + const ingresses = this.matchingIngresses || []; + const summaries = this._summaries; + + if (ingresses.length) { + rows.unshift(useResourceCardRow(this.t('component.resource.detail.card.resourcesCard.rows.ingresses'), ingresses, undefined, undefined, '#ingresses')); + } else if (summaries?.ingresses?.count) { + rows.unshift(useResourceCardRowFromSummary(this.t('component.resource.detail.card.resourcesCard.rows.ingresses'), summaries.ingresses, '#ingresses')); + } + + if (services.length) { + rows.unshift(useResourceCardRow(this.t('component.resource.detail.card.resourcesCard.rows.services'), services, undefined, undefined, '#services')); + } else if (summaries?.services?.count) { + rows.unshift(useResourceCardRowFromSummary(this.t('component.resource.detail.card.resourcesCard.rows.services'), summaries.services, '#services')); + } + + return rows; } get podsCard() { @@ -779,8 +878,11 @@ export default class Workload extends WorkloadService { const scalingTypes = [WORKLOAD_TYPES.DEPLOYMENT, WORKLOAD_TYPES.STATEFUL_SET]; const canScale = this.canUpdate && scalingTypes.includes(this.type); + const summaryData = this._summaries?.pods || null; + const hasPods = this.pods?.length > 0; + const hasSummary = summaryData?.count > 0; - if (!this.pods || (this.pods.length === 0 && !canScale)) { + if (!hasPods && !hasSummary && !canScale) { return null; } @@ -789,6 +891,7 @@ export default class Workload extends WorkloadService { props: { title: this.t('component.resource.detail.card.podsCard.title'), resources: this.pods, + summaryData, showScaling: canScale, onIncrease: () => this.scale(true), onDecrease: () => this.scale(false), @@ -818,6 +921,7 @@ export default class Workload extends WorkloadService { return [ this.podsCard, this.jobsCard, + this.resourcesCard, this.insightCard, ...this._cards ]; diff --git a/shell/plugins/dashboard-store/__tests__/resource-class.test.ts b/shell/plugins/dashboard-store/__tests__/resource-class.test.ts index 77b03bc2ffb..cd9e8314ee9 100644 --- a/shell/plugins/dashboard-store/__tests__/resource-class.test.ts +++ b/shell/plugins/dashboard-store/__tests__/resource-class.test.ts @@ -399,6 +399,99 @@ describe('class: Resource', () => { expect(cards).toHaveLength(0); }); + + it('should include the resources card when relationships exist', () => { + const resource = new Resource({ + type: 'test', + metadata: { + relationships: [ + { + rel: 'uses', toType: 'svc', toId: 'a' + }, + { + rel: 'uses', fromType: 'pod', fromId: 'b' + }, + ] + } + }, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: jest.fn(), + rootGetters: { + 'i18n/t': (key: string) => key, + 'cluster/all': () => [] + }, + }); + + const cards = resource.cards; + + expect(cards).toHaveLength(1); + expect(cards[0].props.title).toBe('component.resource.detail.card.resourcesCard.title'); + }); + }); + + describe('getter: resourcesCard', () => { + it('should return null when there are no relationships', () => { + const resource = new Resource({ type: 'test' }, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: jest.fn(), + rootGetters: { 'i18n/t': (key: string) => key }, + }); + + expect(resource.resourcesCard).toBeNull(); + }); + + it('should return rows for both referredToBy and refersTo when relationships exist in both directions', () => { + const resource = new Resource({ + type: 'test', + metadata: { + relationships: [ + { + rel: 'owner', fromType: 'rs', fromId: 'r-1' + }, + { + rel: 'uses', toType: 'svc', toId: 's-1' + }, + { + rel: 'uses', toType: 'svc', toId: 's-2' + }, + ] + } + }, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: jest.fn(), + rootGetters: { 'i18n/t': (key: string) => key }, + }); + + const rows = resource.resourcesCardRows; + + expect(rows).toHaveLength(2); + expect(rows[0].label).toBe('component.resource.detail.card.resourcesCard.rows.referredToBy'); + expect(rows[0].counts[0].count).toBe(1); + expect(rows[1].label).toBe('component.resource.detail.card.resourcesCard.rows.refersTo'); + expect(rows[1].counts[0].count).toBe(2); + }); + + it('should omit a direction with no relationships', () => { + const resource = new Resource({ + type: 'test', + metadata: { + relationships: [ + { + rel: 'uses', toType: 'svc', toId: 's-1' + }, + ] + } + }, { + getters: { schemaFor: () => ({ linkFor: jest.fn() }) }, + dispatch: jest.fn(), + rootGetters: { 'i18n/t': (key: string) => key }, + }); + + const rows = resource.resourcesCardRows; + + expect(rows).toHaveLength(1); + expect(rows[0].label).toBe('component.resource.detail.card.resourcesCard.rows.refersTo'); + }); }); describe('getter: insightCardProps', () => { diff --git a/shell/plugins/dashboard-store/resource-class.js b/shell/plugins/dashboard-store/resource-class.js index d63ac5d8a70..49577c0b33d 100644 --- a/shell/plugins/dashboard-store/resource-class.js +++ b/shell/plugins/dashboard-store/resource-class.js @@ -40,7 +40,7 @@ import { ExtensionPoint, ActionLocation } from '@shell/core/types'; import { getApplicableExtensionEnhancements } from '@shell/core/plugin-helpers'; import { parse } from '@shell/utils/selector'; import { EVENT } from '@shell/config/types'; -import { useResourceCardRow } from '@shell/components/Resource/Detail/Card/StateCard/composables'; +import { useResourceCardRow, useResourceCardRowFromRelationships } from '@shell/components/Resource/Detail/Card/StateCard/composables'; export const DNS_LIKE_TYPES = ['dnsLabel', 'dnsLabelRestricted', 'hostname']; @@ -2070,7 +2070,7 @@ export default class Resource { if ( r.selector ) { // A selector is a stringified version of a matchLabel (https://github.com/kubernetes/apimachinery/blob/master/pkg/labels/selector.go#L1010) - addObjects(out.selectors, { + addObject(out.selectors, { type: r.toType, namespace: r.toNamespace, selector: r.selector @@ -2080,7 +2080,7 @@ export default class Resource { let namespace = r[`${ direction }Namespace`]; let name = r[`${ direction }Id`]; - if ( !namespace && name.includes('/') ) { + if ( !namespace && name?.includes('/') ) { const idx = name.indexOf('/'); namespace = name.substr(0, idx); @@ -2242,12 +2242,58 @@ export default class Resource { }; } + get _resourcesCardRows() { + const rows = []; + const relationships = this.metadata?.relationships || []; + + const referredToByRels = relationships.filter((r) => r.fromType && !r.selector); + const refersToRels = relationships.filter((r) => r.toType && !r.selector && !r.fromType); + + if (referredToByRels.length) { + rows.push(useResourceCardRowFromRelationships( + this.t('component.resource.detail.card.resourcesCard.rows.referredToBy'), + referredToByRels, + { hash: '#related' } + )); + } + + if (refersToRels.length) { + rows.push(useResourceCardRowFromRelationships( + this.t('component.resource.detail.card.resourcesCard.rows.refersTo'), + refersToRels, + { hash: '#related' } + )); + } + + return rows; + } + + get resourcesCardRows() { + return this._resourcesCardRows; + } + + get resourcesCard() { + const rows = this.resourcesCardRows; + + if (!rows.length) { + return null; + } + + return { + component: markRaw(defineAsyncComponent(() => import('@shell/components/Resource/Detail/Card/StateCard/index.vue'))), + props: { + title: this.t('component.resource.detail.card.resourcesCard.title'), + rows + } + }; + } + get _cards() { // All cards are opt in, we're leaving the insights card as part of the base resource since it should proliferate to most resources return []; } get cards() { - return this._cards; + return [this.resourcesCard, ...this._cards].filter((c) => c); } } diff --git a/shell/plugins/steve/__tests__/actions.test.ts b/shell/plugins/steve/__tests__/actions.test.ts new file mode 100644 index 00000000000..56d0dad9b4f --- /dev/null +++ b/shell/plugins/steve/__tests__/actions.test.ts @@ -0,0 +1,155 @@ +import actions from '@shell/plugins/steve/actions'; +import paginationUtils from '@shell/utils/pagination-utils'; +import stevePaginationUtils from '@shell/plugins/steve/steve-pagination-utils'; +import { PaginationParamFilter } from '@shell/types/store/pagination.types'; + +const { fetchSummary } = actions; + +describe('steve: actions:', () => { + describe('fetchSummary', () => { + const schema = { + id: 'pod', + links: { collection: '/v1/pods' }, + attributes: { namespaced: true }, + }; + + const baseCtx = () => ({ + getters: { + normalizeType: (type: string) => type, + schemaFor: (type: string) => (type === 'pod' ? schema : undefined), + }, + dispatch: jest.fn(), + rootGetters: {}, + }); + + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(paginationUtils, 'isSteveCacheEnabled').mockReturnValue(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return undefined and warn when schema is not found', async() => { + const ctx = baseCtx(); + const result = await fetchSummary.call({}, ctx, { type: 'nonexistent', opt: { summaryField: 'metadata.state.name' } }); + + expect(result).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('no schema found')); + }); + + it('should return undefined and warn when VAI is not enabled', async() => { + jest.spyOn(paginationUtils, 'isSteveCacheEnabled').mockReturnValue(false); + const ctx = baseCtx(); + const result = await fetchSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name' } }); + + expect(result).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('VAI is not enabled')); + }); + + it('should return undefined and warn when summaryField is missing', async() => { + const ctx = baseCtx(); + const result = await fetchSummary.call({}, ctx, { type: 'pod', opt: {} }); + + expect(result).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('summaryField is required')); + }); + + it('should construct the correct URL with summary and exclude params', async() => { + const ctx = baseCtx(); + + ctx.dispatch.mockResolvedValue({ count: 5, summary: [{ property: 'metadata.state.name', counts: { running: 5 } }] }); + + await fetchSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name' } }); + + const requestUrl = ctx.dispatch.mock.calls[0][1].opt.url; + + expect(requestUrl).toContain('summary=metadata.state.name'); + expect(requestUrl).toContain('exclude=metadata'); + expect(requestUrl).toContain('exclude=spec'); + expect(requestUrl).toContain('exclude=status'); + }); + + it('should append namespace to path for namespaced resources', async() => { + const ctx = baseCtx(); + + ctx.dispatch.mockResolvedValue({ count: 2, summary: null }); + + await fetchSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name', namespaced: 'cattle-system' } }); + + const requestUrl = ctx.dispatch.mock.calls[0][1].opt.url; + + expect(requestUrl).toMatch(/\/v1\/pods\/cattle-system\?/); + }); + + it('should not append namespace when schema is not namespaced', async() => { + const nonNsSchema = { ...schema, attributes: { namespaced: false } }; + const ctx = baseCtx(); + + ctx.getters.schemaFor = () => nonNsSchema; + ctx.dispatch.mockResolvedValue({ count: 1, summary: null }); + + await fetchSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name', namespaced: 'default' } }); + + const requestUrl = ctx.dispatch.mock.calls[0][1].opt.url; + + expect(requestUrl).not.toContain('/default'); + }); + + it('should append filter params when filters are provided', async() => { + const ctx = baseCtx(); + const filters = [PaginationParamFilter.createSingleField({ field: 'metadata.namespace', value: 'default' })]; + + jest.spyOn(stevePaginationUtils, 'convertPaginationParams').mockReturnValue('filter=metadata.namespace%3Ddefault'); + ctx.dispatch.mockResolvedValue({ count: 3, summary: null }); + + await fetchSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name', filters } }); + + const requestUrl = ctx.dispatch.mock.calls[0][1].opt.url; + + expect(requestUrl).toContain('filter='); + expect(stevePaginationUtils.convertPaginationParams).toHaveBeenCalledWith(expect.objectContaining({ filters })); + }); + + it('should return count and summary from the response', async() => { + const ctx = baseCtx(); + const apiResponse = { + count: 10, + summary: [{ property: 'metadata.state.name', counts: { running: 7, error: 3 } }] + }; + + ctx.dispatch.mockResolvedValue(apiResponse); + + const result = await fetchSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name' } }); + + expect(result).toStrictEqual({ + count: 10, + summary: [{ property: 'metadata.state.name', counts: { running: 7, error: 3 } }] + }); + }); + + it('should default count to 0 and summary to null when response is empty', async() => { + const ctx = baseCtx(); + + ctx.dispatch.mockResolvedValue({}); + + const result = await fetchSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name' } }); + + expect(result).toStrictEqual({ count: 0, summary: null }); + }); + + it('should return undefined and warn when the request fails', async() => { + const ctx = baseCtx(); + + ctx.dispatch.mockRejectedValue(new Error('network error')); + + const result = await fetchSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name' } }); + + expect(result).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('summary API request failed'), expect.any(Error)); + }); + }); +}); diff --git a/shell/plugins/steve/actions.js b/shell/plugins/steve/actions.js index cce58798d72..dc8360a55eb 100644 --- a/shell/plugins/steve/actions.js +++ b/shell/plugins/steve/actions.js @@ -10,6 +10,7 @@ import { NAMESPACE } from '@shell/config/types'; import { handleKubeApiHeaderWarnings } from '@shell/plugins/steve/header-warnings'; import { steveCleanForDownload } from '@shell/plugins/steve/resource-utils'; import paginationUtils from '@shell/utils/pagination-utils'; +import stevePaginationUtils from '@shell/plugins/steve/steve-pagination-utils'; export default { @@ -221,6 +222,75 @@ export default { } }, + /** + * Fetch aggregated state counts for a resource type via the Steve summary API. + * Requires VAI (ui-sql-cache) to be enabled; returns undefined otherwise. + * + * @param {string} type - Resource type (e.g. 'pod', 'service') + * @param {object} [opt] - Options object + * @param {string} opt.summaryField - Field to aggregate counts by. + * Must be a field indexed by the VAI cache (see StevePaginationUtils.VALID_FIELDS in steve-pagination-utils.ts) + * @param {string} [opt.namespaced] - Namespace to scope the request to (only applies to namespaced resource types) + * @param {PaginationParamFilter[]} [opt.filters] - Pre-built filters from PaginationParamFilter.createSingleField() + * @returns {Promise<{ count: number, summary: { property: string, counts: Record }[] | null } | undefined>} + * + * @example + * const nsFilter = PaginationParamFilter.createSingleField({ field: 'metadata.namespace', value: 'default' }); + * const result = await dispatch('fetchSummary', { type: 'pod', opt: { filters: [nsFilter] } }); + */ + async fetchSummary({ getters, dispatch, rootGetters }, { type, opt = {} }) { + type = getters.normalizeType(type); + const schema = getters.schemaFor(type); + + if (!schema) { + console.warn(`fetchSummary: no schema found for type "${ type }"`); // eslint-disable-line no-console + + return undefined; + } + + if (!paginationUtils.isSteveCacheEnabled({ rootGetters })) { + console.warn(`fetchSummary: VAI is not enabled, summary API unavailable for type "${ type }"`); // eslint-disable-line no-console + + return undefined; + } + + if (!opt.summaryField) { + console.warn(`fetchSummary: summaryField is required and must be a string for type "${ type }"`); // eslint-disable-line no-console + + return undefined; + } + + try { + const url = new URL(schema.links.collection, window.location.origin); + + if (schema.attributes?.namespaced && opt.namespaced) { + url.pathname += `/${ opt.namespaced }`; + } + + url.searchParams.set('summary', opt.summaryField); + url.searchParams.append('exclude', 'metadata'); + url.searchParams.append('exclude', 'spec'); + url.searchParams.append('exclude', 'status'); + + if (opt.filters?.length) { + const filterParams = new URLSearchParams(stevePaginationUtils.convertPaginationParams({ schema, filters: opt.filters })); + + filterParams.forEach((v, k) => url.searchParams.append(k, v)); + } + + const res = await dispatch('request', { opt: { url: url.pathname + url.search } }); + + return { + count: res.count || 0, + summary: res.summary || null + }; + } catch (e) { + console.warn(`fetchSummary: summary API request failed for type "${ type }"`, e); // eslint-disable-line no-console + + return undefined; + } + }, + promptRestore({ commit, state }, resources ) { commit('action-menu/togglePromptRestore', resources, { root: true }); }, From cf82412115879f03fadf3d4456bf7d076dbe76ff Mon Sep 17 00:00:00 2001 From: Cody Jackson Date: Wed, 20 May 2026 23:12:33 +0000 Subject: [PATCH 2/7] Rename fetchSummary to fetchResourceSummary, add useResourceSummary composable Add a reactive composable that watches store generation for a resource type and refetches summary data on changes. Cleans up automatically via onScopeDispose when the component unmounts. --- .../Detail/Card/StateCard/composables.ts | 45 ++++++++++++++++++- shell/models/workload.js | 6 +-- shell/plugins/steve/__tests__/actions.test.ts | 24 +++++----- shell/plugins/steve/actions.js | 12 ++--- 4 files changed, 65 insertions(+), 22 deletions(-) diff --git a/shell/components/Resource/Detail/Card/StateCard/composables.ts b/shell/components/Resource/Detail/Card/StateCard/composables.ts index 85743d38141..5044c3e838a 100644 --- a/shell/components/Resource/Detail/Card/StateCard/composables.ts +++ b/shell/components/Resource/Detail/Card/StateCard/composables.ts @@ -2,11 +2,14 @@ import { Count, Props as ResourceRowProps } from '@shell/components/Resource/Det import { useI18n } from '@shell/composables/useI18n'; import { INGRESS, SERVICE } from '@shell/config/types'; import { isHigherAlert, StateColor } from '@shell/utils/style'; -import { computed, Ref, toValue } from 'vue'; +import { + computed, onScopeDispose, ref, Ref, toValue, watch +} from 'vue'; import { useStore } from 'vuex'; import { Props as StateCardProps } from '@shell/components/Resource/Detail/Card/StateCard/types'; import { RouteLocationRaw } from 'vue-router'; import { colorForState as colorForStateFn, stateDisplay as stateDisplayFn } from '@shell/plugins/dashboard-store/resource-class'; +import { PaginationParamFilter } from '@shell/types/store/pagination.types'; export interface SummaryResult { count: number; @@ -262,3 +265,43 @@ export function useDefaultWorkloadInsightsCardProps(): StateCardProps { rows }; } + +export interface ResourceSummaryOpt { + summaryField: string; + namespaced?: string; + filters?: PaginationParamFilter[]; +} + +/** + * Composable that fetches a resource summary and keeps it updated by watching for resource changes. + * Automatically stops watching when the component is unmounted or the scope is disposed. + * + * Must be called during component setup. + */ +export function useResourceSummary(type: string, opt: ResourceSummaryOpt) { + const store = useStore(); + const count = ref(0); + const summary = ref(null); + + const normalizedType = store.getters['cluster/normalizeType']?.(type) || type; + + async function fetch() { + const result = await store.dispatch('cluster/fetchResourceSummary', { type, opt }); + + if (result) { + count.value = result.count; + summary.value = result.summary; + } + } + + fetch(); + + const stopWatch = watch( + () => store.getters['cluster/generation']?.(normalizedType), + () => fetch() + ); + + onScopeDispose(stopWatch); + + return { count, summary }; +} diff --git a/shell/models/workload.js b/shell/models/workload.js index 49f0a2aa2f3..384ddd1691f 100644 --- a/shell/models/workload.js +++ b/shell/models/workload.js @@ -608,15 +608,15 @@ export default class Workload extends WorkloadService { } }); - summaries.pods = await this.$dispatch('fetchSummary', { type: POD, opt: { summaryField: 'metadata.state.name', filters } }); + summaries.pods = await this.$dispatch('fetchResourceSummary', { type: POD, opt: { summaryField: 'metadata.state.name', filters } }); } - summaries.services = await this.$dispatch('fetchSummary', { + summaries.services = await this.$dispatch('fetchResourceSummary', { type: SERVICE, opt: { summaryField: 'metadata.state.name', filters: [nsFilter] } }); - summaries.ingresses = await this.$dispatch('fetchSummary', { + summaries.ingresses = await this.$dispatch('fetchResourceSummary', { type: INGRESS, opt: { summaryField: 'metadata.state.name', filters: [nsFilter] } }); diff --git a/shell/plugins/steve/__tests__/actions.test.ts b/shell/plugins/steve/__tests__/actions.test.ts index 56d0dad9b4f..ebacad46c00 100644 --- a/shell/plugins/steve/__tests__/actions.test.ts +++ b/shell/plugins/steve/__tests__/actions.test.ts @@ -3,10 +3,10 @@ import paginationUtils from '@shell/utils/pagination-utils'; import stevePaginationUtils from '@shell/plugins/steve/steve-pagination-utils'; import { PaginationParamFilter } from '@shell/types/store/pagination.types'; -const { fetchSummary } = actions; +const { fetchResourceSummary } = actions; describe('steve: actions:', () => { - describe('fetchSummary', () => { + describe('fetchResourceSummary', () => { const schema = { id: 'pod', links: { collection: '/v1/pods' }, @@ -35,7 +35,7 @@ describe('steve: actions:', () => { it('should return undefined and warn when schema is not found', async() => { const ctx = baseCtx(); - const result = await fetchSummary.call({}, ctx, { type: 'nonexistent', opt: { summaryField: 'metadata.state.name' } }); + const result = await fetchResourceSummary.call({}, ctx, { type: 'nonexistent', opt: { summaryField: 'metadata.state.name' } }); expect(result).toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('no schema found')); @@ -44,7 +44,7 @@ describe('steve: actions:', () => { it('should return undefined and warn when VAI is not enabled', async() => { jest.spyOn(paginationUtils, 'isSteveCacheEnabled').mockReturnValue(false); const ctx = baseCtx(); - const result = await fetchSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name' } }); + const result = await fetchResourceSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name' } }); expect(result).toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('VAI is not enabled')); @@ -52,7 +52,7 @@ describe('steve: actions:', () => { it('should return undefined and warn when summaryField is missing', async() => { const ctx = baseCtx(); - const result = await fetchSummary.call({}, ctx, { type: 'pod', opt: {} }); + const result = await fetchResourceSummary.call({}, ctx, { type: 'pod', opt: {} }); expect(result).toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('summaryField is required')); @@ -63,7 +63,7 @@ describe('steve: actions:', () => { ctx.dispatch.mockResolvedValue({ count: 5, summary: [{ property: 'metadata.state.name', counts: { running: 5 } }] }); - await fetchSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name' } }); + await fetchResourceSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name' } }); const requestUrl = ctx.dispatch.mock.calls[0][1].opt.url; @@ -78,7 +78,7 @@ describe('steve: actions:', () => { ctx.dispatch.mockResolvedValue({ count: 2, summary: null }); - await fetchSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name', namespaced: 'cattle-system' } }); + await fetchResourceSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name', namespaced: 'cattle-system' } }); const requestUrl = ctx.dispatch.mock.calls[0][1].opt.url; @@ -92,7 +92,7 @@ describe('steve: actions:', () => { ctx.getters.schemaFor = () => nonNsSchema; ctx.dispatch.mockResolvedValue({ count: 1, summary: null }); - await fetchSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name', namespaced: 'default' } }); + await fetchResourceSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name', namespaced: 'default' } }); const requestUrl = ctx.dispatch.mock.calls[0][1].opt.url; @@ -106,7 +106,7 @@ describe('steve: actions:', () => { jest.spyOn(stevePaginationUtils, 'convertPaginationParams').mockReturnValue('filter=metadata.namespace%3Ddefault'); ctx.dispatch.mockResolvedValue({ count: 3, summary: null }); - await fetchSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name', filters } }); + await fetchResourceSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name', filters } }); const requestUrl = ctx.dispatch.mock.calls[0][1].opt.url; @@ -123,7 +123,7 @@ describe('steve: actions:', () => { ctx.dispatch.mockResolvedValue(apiResponse); - const result = await fetchSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name' } }); + const result = await fetchResourceSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name' } }); expect(result).toStrictEqual({ count: 10, @@ -136,7 +136,7 @@ describe('steve: actions:', () => { ctx.dispatch.mockResolvedValue({}); - const result = await fetchSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name' } }); + const result = await fetchResourceSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name' } }); expect(result).toStrictEqual({ count: 0, summary: null }); }); @@ -146,7 +146,7 @@ describe('steve: actions:', () => { ctx.dispatch.mockRejectedValue(new Error('network error')); - const result = await fetchSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name' } }); + const result = await fetchResourceSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name' } }); expect(result).toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('summary API request failed'), expect.any(Error)); diff --git a/shell/plugins/steve/actions.js b/shell/plugins/steve/actions.js index dc8360a55eb..f8981b03855 100644 --- a/shell/plugins/steve/actions.js +++ b/shell/plugins/steve/actions.js @@ -236,26 +236,26 @@ export default { * * @example * const nsFilter = PaginationParamFilter.createSingleField({ field: 'metadata.namespace', value: 'default' }); - * const result = await dispatch('fetchSummary', { type: 'pod', opt: { filters: [nsFilter] } }); + * const result = await dispatch('fetchResourceSummary', { type: 'pod', opt: { filters: [nsFilter] } }); */ - async fetchSummary({ getters, dispatch, rootGetters }, { type, opt = {} }) { + async fetchResourceSummary({ getters, dispatch, rootGetters }, { type, opt = {} }) { type = getters.normalizeType(type); const schema = getters.schemaFor(type); if (!schema) { - console.warn(`fetchSummary: no schema found for type "${ type }"`); // eslint-disable-line no-console + console.warn(`fetchResourceSummary: no schema found for type "${ type }"`); // eslint-disable-line no-console return undefined; } if (!paginationUtils.isSteveCacheEnabled({ rootGetters })) { - console.warn(`fetchSummary: VAI is not enabled, summary API unavailable for type "${ type }"`); // eslint-disable-line no-console + console.warn(`fetchResourceSummary: VAI is not enabled, summary API unavailable for type "${ type }"`); // eslint-disable-line no-console return undefined; } if (!opt.summaryField) { - console.warn(`fetchSummary: summaryField is required and must be a string for type "${ type }"`); // eslint-disable-line no-console + console.warn(`fetchResourceSummary: summaryField is required and must be a string for type "${ type }"`); // eslint-disable-line no-console return undefined; } @@ -285,7 +285,7 @@ export default { summary: res.summary || null }; } catch (e) { - console.warn(`fetchSummary: summary API request failed for type "${ type }"`, e); // eslint-disable-line no-console + console.warn(`fetchResourceSummary: summary API request failed for type "${ type }"`, e); // eslint-disable-line no-console return undefined; } From ded904c585fc5f7410757a46ced12c44e640d182 Mon Sep 17 00:00:00 2001 From: Cody Jackson Date: Wed, 20 May 2026 23:18:44 +0000 Subject: [PATCH 3/7] Add tests for useResourceSummary composable Cover initial fetch, undefined handling, reactive updates on generation change, and automatic cleanup on scope disposal. --- .../StateCard/__tests__/composables.test.ts | 97 ++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/shell/components/Resource/Detail/Card/StateCard/__tests__/composables.test.ts b/shell/components/Resource/Detail/Card/StateCard/__tests__/composables.test.ts index 6632237c506..da251cbb6bb 100644 --- a/shell/components/Resource/Detail/Card/StateCard/__tests__/composables.test.ts +++ b/shell/components/Resource/Detail/Card/StateCard/__tests__/composables.test.ts @@ -1,6 +1,20 @@ -import { useResourceCardRow, useResourceCardRowFromSummary, useResourceCardRowFromRelationships } from '@shell/components/Resource/Detail/Card/StateCard/composables'; +import { effectScope, ref } from 'vue'; +import { useResourceCardRow, useResourceCardRowFromSummary, useResourceCardRowFromRelationships, useResourceSummary } from '@shell/components/Resource/Detail/Card/StateCard/composables'; import type { SummaryResult } from '@shell/components/Resource/Detail/Card/StateCard/composables'; +const generation = ref(0); +const mockStore: any = { + getters: { + 'cluster/normalizeType': (type: string) => type, + 'cluster/generation': () => generation.value, + }, + dispatch: jest.fn(), +}; + +jest.mock('vuex', () => ({ useStore: () => mockStore })); + +const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0)); + describe('useResourceCardRow', () => { describe('with default keys', () => { it('should return undefined counts when resources is empty', () => { @@ -333,3 +347,84 @@ describe('useResourceCardRowFromRelationships', () => { })); }); }); + +describe('useResourceSummary', () => { + beforeEach(() => { + jest.clearAllMocks(); + generation.value = 0; + }); + + it('should fetch initial summary data', async() => { + mockStore.dispatch.mockResolvedValue({ + count: 5, + summary: [{ property: 'metadata.state.name', counts: { active: 5 } }] + }); + + const scope = effectScope(); + const { count, summary } = scope.run(() => useResourceSummary('pod', { summaryField: 'metadata.state.name' }))!; + + await flushPromises(); + + expect(mockStore.dispatch).toHaveBeenCalledWith('cluster/fetchResourceSummary', { + type: 'pod', + opt: { summaryField: 'metadata.state.name' } + }); + expect(count.value).toBe(5); + expect(summary.value).toStrictEqual([{ property: 'metadata.state.name', counts: { active: 5 } }]); + + scope.stop(); + }); + + it('should not update refs when fetch returns undefined', async() => { + mockStore.dispatch.mockResolvedValue(undefined); + + const scope = effectScope(); + const { count, summary } = scope.run(() => useResourceSummary('pod', { summaryField: 'metadata.state.name' }))!; + + await flushPromises(); + + expect(count.value).toBe(0); + expect(summary.value).toBeNull(); + + scope.stop(); + }); + + it('should refetch when generation changes', async() => { + mockStore.dispatch + .mockResolvedValueOnce({ count: 2, summary: [{ property: 'metadata.state.name', counts: { active: 2 } }] }) + .mockResolvedValueOnce({ count: 3, summary: [{ property: 'metadata.state.name', counts: { active: 3 } }] }); + + const scope = effectScope(); + const { count, summary } = scope.run(() => useResourceSummary('pod', { summaryField: 'metadata.state.name' }))!; + + await flushPromises(); + expect(count.value).toBe(2); + + generation.value++; + await flushPromises(); + + expect(mockStore.dispatch).toHaveBeenCalledTimes(2); + expect(count.value).toBe(3); + expect(summary.value).toStrictEqual([{ property: 'metadata.state.name', counts: { active: 3 } }]); + + scope.stop(); + }); + + it('should stop watching when scope is disposed', async() => { + mockStore.dispatch.mockResolvedValue({ count: 1, summary: null }); + + const scope = effectScope(); + + scope.run(() => useResourceSummary('pod', { summaryField: 'metadata.state.name' })); + await flushPromises(); + + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); + + scope.stop(); + + generation.value++; + await flushPromises(); + + expect(mockStore.dispatch).toHaveBeenCalledTimes(1); + }); +}); From 7627de0c7f64745eec65c6cbd3638f65638b9157 Mon Sep 17 00:00:00 2001 From: Cody Jackson Date: Wed, 20 May 2026 23:36:57 +0000 Subject: [PATCH 4/7] Fix review findings across summary API and resource cards - Fix broken reactivity in useDefaultResources (toValue inside computed) - Fix division by zero in StatusCard percent function - Fix race condition in useResourceSummary with fetchId staleness guard - Fix inconsistent color assignment in StatusCard rowAccumulator - Fix inconsistent sort comparator in useResourceCardRowFromRelationships - Add color fallback for missing stateSimpleColor in StatusCard - Add error handling for fire-and-forget async in useResourceSummary - Parallelize service/ingress summary fetches with Promise.all - Rename opt.namespaced to opt.namespace for clarity - Replace hardcoded 'Resources' with i18n key - Extract duplicated 'metadata.state.name' to constant - Use nullish coalescing for res.count - Fix JSDoc example to include required summaryField --- .../Detail/Card/StateCard/composables.ts | 32 +++++++++++++------ .../Resource/Detail/Card/StatusCard/index.vue | 9 +++--- shell/models/workload.js | 27 ++++++++++------ shell/plugins/steve/__tests__/actions.test.ts | 4 +-- shell/plugins/steve/actions.js | 13 +++++--- 5 files changed, 55 insertions(+), 30 deletions(-) diff --git a/shell/components/Resource/Detail/Card/StateCard/composables.ts b/shell/components/Resource/Detail/Card/StateCard/composables.ts index 5044c3e838a..95252a6df02 100644 --- a/shell/components/Resource/Detail/Card/StateCard/composables.ts +++ b/shell/components/Resource/Detail/Card/StateCard/composables.ts @@ -163,7 +163,11 @@ export function useResourceCardRowFromRelationships(label: string, relationships return 1; } - return left.count >= right.count ? -1 : 1; + if (left.count === right.count) { + return 0; + } + + return left.count > right.count ? -1 : 1; }); return { @@ -181,8 +185,7 @@ export interface Pairs { } export function useDefaultResources(pairs: Ref) { - const pairsValue = toValue(pairs); - const rows = computed(() => pairsValue.map(({ label, resources, to }) => useResourceCardRow(label, resources, undefined, undefined, to))); + const rows = computed(() => toValue(pairs).map(({ label, resources, to }) => useResourceCardRow(label, resources, undefined, undefined, to))); return rows; } @@ -236,7 +239,7 @@ export function useDefaultWorkloadResources(services?: any[], ingresses?: any[], const rows = useDefaultResources(remainingPairs); return { - title: 'Resources', + title: i18n.t('component.resource.detail.card.resourcesCard.title'), rows: rows.value }; } @@ -268,7 +271,7 @@ export function useDefaultWorkloadInsightsCardProps(): StateCardProps { export interface ResourceSummaryOpt { summaryField: string; - namespaced?: string; + namespace?: string; filters?: PaginationParamFilter[]; } @@ -284,13 +287,24 @@ export function useResourceSummary(type: string, opt: ResourceSummaryOpt) { const summary = ref(null); const normalizedType = store.getters['cluster/normalizeType']?.(type) || type; + let fetchId = 0; async function fetch() { - const result = await store.dispatch('cluster/fetchResourceSummary', { type, opt }); + const id = ++fetchId; + + try { + const result = await store.dispatch('cluster/fetchResourceSummary', { type, opt }); + + if (id !== fetchId) { + return; + } - if (result) { - count.value = result.count; - summary.value = result.summary; + if (result) { + count.value = result.count; + summary.value = result.summary; + } + } catch (e) { + console.warn(`useResourceSummary: fetch failed for type "${ type }"`, e); // eslint-disable-line no-console } } diff --git a/shell/components/Resource/Detail/Card/StatusCard/index.vue b/shell/components/Resource/Detail/Card/StatusCard/index.vue index 674785c30a4..3844a864c11 100644 --- a/shell/components/Resource/Detail/Card/StatusCard/index.vue +++ b/shell/components/Resource/Detail/Card/StatusCard/index.vue @@ -61,7 +61,7 @@ const segmentAccumulator = computed(() => { } } else { props.resources?.forEach((resource: any) => { - const color: StateColor = resource.stateSimpleColor; + const color: StateColor = resource.stateSimpleColor || 'disabled'; accumulator[color] = accumulator[color] || { count: 0 }; accumulator[color].count++; @@ -90,9 +90,10 @@ const rowAccumulator = computed(() => { } } else { props.resources?.forEach((resource: any) => { - accumulator[resource.stateDisplay] = accumulator[resource.stateDisplay] || { count: 0, color: 'disabled' as StateColor }; + const color = (resource.stateSimpleColor?.replace('text-', '') || 'disabled') as StateColor; + + accumulator[resource.stateDisplay] = accumulator[resource.stateDisplay] || { count: 0, color }; accumulator[resource.stateDisplay].count++; - accumulator[resource.stateDisplay].color = resource.stateSimpleColor.replace('text-', '') as StateColor; }); } @@ -100,7 +101,7 @@ const rowAccumulator = computed(() => { }); const percent = (count: number, total: number) => { - return count / total * 100; + return total > 0 ? count / total * 100 : 0; }; const count = computed(() => { diff --git a/shell/models/workload.js b/shell/models/workload.js index 384ddd1691f..f37f473a525 100644 --- a/shell/models/workload.js +++ b/shell/models/workload.js @@ -590,6 +590,7 @@ export default class Workload extends WorkloadService { async fetchSummaries() { const ns = this.metadata.namespace; const summaries = {}; + const summaryField = 'metadata.state.name'; const nsFilter = PaginationParamFilter.createSingleField({ field: 'metadata.namespace', value: ns }); const podSelectorStr = this.podSelector; @@ -608,18 +609,24 @@ export default class Workload extends WorkloadService { } }); - summaries.pods = await this.$dispatch('fetchResourceSummary', { type: POD, opt: { summaryField: 'metadata.state.name', filters } }); + summaries.pods = await this.$dispatch('fetchResourceSummary', { type: POD, opt: { summaryField, filters } }); } - summaries.services = await this.$dispatch('fetchResourceSummary', { - type: SERVICE, - opt: { summaryField: 'metadata.state.name', filters: [nsFilter] } - }); - - summaries.ingresses = await this.$dispatch('fetchResourceSummary', { - type: INGRESS, - opt: { summaryField: 'metadata.state.name', filters: [nsFilter] } - }); + // Service and ingress summaries are scoped by namespace only (not by workload selector) + // because the relationship between workloads and services/ingresses is not label-based. + const [services, ingresses] = await Promise.all([ + this.$dispatch('fetchResourceSummary', { + type: SERVICE, + opt: { summaryField, filters: [nsFilter] } + }), + this.$dispatch('fetchResourceSummary', { + type: INGRESS, + opt: { summaryField, filters: [nsFilter] } + }), + ]); + + summaries.services = services; + summaries.ingresses = ingresses; set(this, '_summaries', summaries); diff --git a/shell/plugins/steve/__tests__/actions.test.ts b/shell/plugins/steve/__tests__/actions.test.ts index ebacad46c00..96cd9cd439a 100644 --- a/shell/plugins/steve/__tests__/actions.test.ts +++ b/shell/plugins/steve/__tests__/actions.test.ts @@ -78,7 +78,7 @@ describe('steve: actions:', () => { ctx.dispatch.mockResolvedValue({ count: 2, summary: null }); - await fetchResourceSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name', namespaced: 'cattle-system' } }); + await fetchResourceSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name', namespace: 'cattle-system' } }); const requestUrl = ctx.dispatch.mock.calls[0][1].opt.url; @@ -92,7 +92,7 @@ describe('steve: actions:', () => { ctx.getters.schemaFor = () => nonNsSchema; ctx.dispatch.mockResolvedValue({ count: 1, summary: null }); - await fetchResourceSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name', namespaced: 'default' } }); + await fetchResourceSummary.call({}, ctx, { type: 'pod', opt: { summaryField: 'metadata.state.name', namespace: 'default' } }); const requestUrl = ctx.dispatch.mock.calls[0][1].opt.url; diff --git a/shell/plugins/steve/actions.js b/shell/plugins/steve/actions.js index f8981b03855..aa6bcced334 100644 --- a/shell/plugins/steve/actions.js +++ b/shell/plugins/steve/actions.js @@ -230,13 +230,16 @@ export default { * @param {object} [opt] - Options object * @param {string} opt.summaryField - Field to aggregate counts by. * Must be a field indexed by the VAI cache (see StevePaginationUtils.VALID_FIELDS in steve-pagination-utils.ts) - * @param {string} [opt.namespaced] - Namespace to scope the request to (only applies to namespaced resource types) + * @param {string} [opt.namespace] - Namespace to scope the request to (only applies to namespaced resource types) * @param {PaginationParamFilter[]} [opt.filters] - Pre-built filters from PaginationParamFilter.createSingleField() * @returns {Promise<{ count: number, summary: { property: string, counts: Record }[] | null } | undefined>} * * @example * const nsFilter = PaginationParamFilter.createSingleField({ field: 'metadata.namespace', value: 'default' }); - * const result = await dispatch('fetchResourceSummary', { type: 'pod', opt: { filters: [nsFilter] } }); + * const result = await dispatch('fetchResourceSummary', { + * type: 'pod', + * opt: { summaryField: 'metadata.state.name', filters: [nsFilter] } + * }); */ async fetchResourceSummary({ getters, dispatch, rootGetters }, { type, opt = {} }) { type = getters.normalizeType(type); @@ -263,8 +266,8 @@ export default { try { const url = new URL(schema.links.collection, window.location.origin); - if (schema.attributes?.namespaced && opt.namespaced) { - url.pathname += `/${ opt.namespaced }`; + if (schema.attributes?.namespaced && opt.namespace) { + url.pathname += `/${ opt.namespace }`; } url.searchParams.set('summary', opt.summaryField); @@ -281,7 +284,7 @@ export default { const res = await dispatch('request', { opt: { url: url.pathname + url.search } }); return { - count: res.count || 0, + count: res.count ?? 0, summary: res.summary || null }; } catch (e) { From 5bbf48e2a941f35eff850b6da9fd4d67c6c2acfb Mon Sep 17 00:00:00 2001 From: Cody Jackson Date: Wed, 20 May 2026 23:45:57 +0000 Subject: [PATCH 5/7] Address PR review feedback - Use ?? instead of || for count/length in StatusCard computed - Add .filter((c) => c) to workload cards getter for null safety - Initialize summaries.pods = null explicitly when no pod selector - Use normalizedType in useResourceSummary dispatch for consistency --- .../components/Resource/Detail/Card/StateCard/composables.ts | 2 +- shell/components/Resource/Detail/Card/StatusCard/index.vue | 4 ++-- shell/models/workload.js | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/shell/components/Resource/Detail/Card/StateCard/composables.ts b/shell/components/Resource/Detail/Card/StateCard/composables.ts index 95252a6df02..3588fdb16cd 100644 --- a/shell/components/Resource/Detail/Card/StateCard/composables.ts +++ b/shell/components/Resource/Detail/Card/StateCard/composables.ts @@ -293,7 +293,7 @@ export function useResourceSummary(type: string, opt: ResourceSummaryOpt) { const id = ++fetchId; try { - const result = await store.dispatch('cluster/fetchResourceSummary', { type, opt }); + const result = await store.dispatch('cluster/fetchResourceSummary', { type: normalizedType, opt }); if (id !== fetchId) { return; diff --git a/shell/components/Resource/Detail/Card/StatusCard/index.vue b/shell/components/Resource/Detail/Card/StatusCard/index.vue index 3844a864c11..b6994adec0d 100644 --- a/shell/components/Resource/Detail/Card/StatusCard/index.vue +++ b/shell/components/Resource/Detail/Card/StatusCard/index.vue @@ -106,10 +106,10 @@ const percent = (count: number, total: number) => { const count = computed(() => { if (summaryStateCounts.value) { - return props.summaryData?.count || 0; + return props.summaryData?.count ?? 0; } - return props.resources?.length || 0; + return props.resources?.length ?? 0; }); const segmentColors = computed(() => Object.keys(segmentAccumulator.value) as StateColor[]); diff --git a/shell/models/workload.js b/shell/models/workload.js index f37f473a525..71c27ddcf10 100644 --- a/shell/models/workload.js +++ b/shell/models/workload.js @@ -595,6 +595,8 @@ export default class Workload extends WorkloadService { const podSelectorStr = this.podSelector; + summaries.pods = null; + if (podSelectorStr) { const filters = [nsFilter]; @@ -931,6 +933,6 @@ export default class Workload extends WorkloadService { this.resourcesCard, this.insightCard, ...this._cards - ]; + ].filter((c) => c); } } From f28be22d74c1eb1adf2c296005175cff22e7478b Mon Sep 17 00:00:00 2001 From: Cody Jackson Date: Thu, 21 May 2026 17:47:53 +0000 Subject: [PATCH 6/7] Fix resource cards to show only relevant rows per workload type - Gate Services/Ingresses rows to only Deployments, DaemonSets, and StatefulSets (not Jobs or CronJobs which lack those tabs) - Remove namespace-wide summary fallback that showed incorrect counts - Filter relationship rows to require specific resource IDs, preventing phantom "missing" entries from generic type references - Add Containers row and Insights card to Pod detail page - Extract simpleColorForState helper for consistent state color usage Fixes #16375 --- .../Detail/Card/StateCard/composables.ts | 8 ++--- shell/models/pod.js | 34 ++++++++++++++++++- shell/models/workload.js | 27 +++++++-------- .../plugins/dashboard-store/resource-class.js | 10 ++++-- 4 files changed, 56 insertions(+), 23 deletions(-) diff --git a/shell/components/Resource/Detail/Card/StateCard/composables.ts b/shell/components/Resource/Detail/Card/StateCard/composables.ts index 3588fdb16cd..b95844fbced 100644 --- a/shell/components/Resource/Detail/Card/StateCard/composables.ts +++ b/shell/components/Resource/Detail/Card/StateCard/composables.ts @@ -8,7 +8,7 @@ import { import { useStore } from 'vuex'; import { Props as StateCardProps } from '@shell/components/Resource/Detail/Card/StateCard/types'; import { RouteLocationRaw } from 'vue-router'; -import { colorForState as colorForStateFn, stateDisplay as stateDisplayFn } from '@shell/plugins/dashboard-store/resource-class'; +import { simpleColorForState, stateDisplay as stateDisplayFn } from '@shell/plugins/dashboard-store/resource-class'; import { PaginationParamFilter } from '@shell/types/store/pagination.types'; export interface SummaryResult { @@ -89,8 +89,7 @@ export function useResourceCardRowFromSummary(label: string, summaryResult: Summ } const tuples: Tuple[] = Object.entries(stateSummary.counts).map(([state, count]) => { - const colorRaw = colorForStateFn(state) as string; - const color = (colorRaw?.replace('text-', '') || 'disabled') as StateColor; + const color = simpleColorForState(state) as StateColor; const display = stateDisplayFn(state) as string; return { @@ -139,8 +138,7 @@ export function useResourceCardRowFromRelationships(label: string, relationships relationships.forEach((r: any) => { const state = (r.state || 'missing').toLowerCase(); - const colorRaw = colorForStateFn(state) as string; - const color = (colorRaw?.replace('text-', '') || 'disabled') as StateColor; + const color = simpleColorForState(state) as StateColor; const display = (stateDisplayFn(state) as string)?.toLowerCase() || state; agg[state] = agg[state] || { diff --git a/shell/models/pod.js b/shell/models/pod.js index 41ce27ca8a8..2416eb1ba49 100644 --- a/shell/models/pod.js +++ b/shell/models/pod.js @@ -1,10 +1,11 @@ import { insertAt } from '@shell/utils/array'; -import { colorForState, stateDisplay } from '@shell/plugins/dashboard-store/resource-class'; +import { colorForState, simpleColorForState, stateDisplay } from '@shell/plugins/dashboard-store/resource-class'; import { NODE, WORKLOAD_TYPES } from '@shell/config/types'; import { escapeHtml, shortenedImage } from '@shell/utils/string'; import WorkloadService from '@shell/models/workload.service'; import { deleteProperty } from '@shell/utils/object'; import { POD_RESTARTS_REG_EX } from '@shell/types/resources/pod'; +import { useResourceCardRow } from '@shell/components/Resource/Detail/Card/StateCard/composables'; export const WORKLOAD_PRIORITY = { [WORKLOAD_TYPES.DEPLOYMENT]: 1, @@ -156,6 +157,37 @@ export default class Pod extends WorkloadService { return initContainers.includes(container); } + get resourceContainers() { + const statuses = [...(this.status?.containerStatuses || []), ...(this.status?.initContainerStatuses || [])]; + + return statuses.map((s) => { + const state = Object.keys(s.state || {})[0] || 'unknown'; + + return { + stateDisplay: stateDisplay(state), + stateSimpleColor: simpleColorForState(state), + }; + }); + } + + get resourcesCardRows() { + const rows = [...this._resourcesCardRows]; + + if (this.resourceContainers.length) { + rows.unshift(useResourceCardRow(this.t('workload.container.titles.containers'), this.resourceContainers, 'stateSimpleColor', 'stateDisplay', '#containers')); + } + + return rows; + } + + get cards() { + return [ + this.resourcesCard, + this.insightCard, + ...this._cards + ].filter((c) => c); + } + get imageNames() { return this.spec.containers.map((container) => shortenedImage(container.image)); } diff --git a/shell/models/workload.js b/shell/models/workload.js index 71c27ddcf10..f1895586780 100644 --- a/shell/models/workload.js +++ b/shell/models/workload.js @@ -8,7 +8,7 @@ import { SEPARATOR } from '@shell/config/workload'; import WorkloadService from '@shell/models/workload.service'; import { matching } from '@shell/utils/selector-typed'; import { defineAsyncComponent, markRaw } from 'vue'; -import { useResourceCardRow, useResourceCardRowFromSummary } from '@shell/components/Resource/Detail/Card/StateCard/composables'; +import { useResourceCardRow } from '@shell/components/Resource/Detail/Card/StateCard/composables'; import { colorForState as colorForStateFn, stateDisplay as stateDisplayFn } from '@shell/plugins/dashboard-store/resource-class'; import { PaginationParamFilter } from '@shell/types/store/pagination.types'; @@ -859,20 +859,19 @@ export default class Workload extends WorkloadService { get resourcesCardRows() { const rows = [...this._resourcesCardRows]; - const services = this.relatedServices || []; - const ingresses = this.matchingIngresses || []; - const summaries = this._summaries; - - if (ingresses.length) { - rows.unshift(useResourceCardRow(this.t('component.resource.detail.card.resourcesCard.rows.ingresses'), ingresses, undefined, undefined, '#ingresses')); - } else if (summaries?.ingresses?.count) { - rows.unshift(useResourceCardRowFromSummary(this.t('component.resource.detail.card.resourcesCard.rows.ingresses'), summaries.ingresses, '#ingresses')); - } + const showsIngressesAndServices = this.type !== WORKLOAD_TYPES.JOB && this.type !== WORKLOAD_TYPES.CRON_JOB; - if (services.length) { - rows.unshift(useResourceCardRow(this.t('component.resource.detail.card.resourcesCard.rows.services'), services, undefined, undefined, '#services')); - } else if (summaries?.services?.count) { - rows.unshift(useResourceCardRowFromSummary(this.t('component.resource.detail.card.resourcesCard.rows.services'), summaries.services, '#services')); + if (showsIngressesAndServices) { + const services = this.relatedServices || []; + const ingresses = this.matchingIngresses || []; + + if (ingresses.length) { + rows.unshift(useResourceCardRow(this.t('component.resource.detail.card.resourcesCard.rows.ingresses'), ingresses, undefined, undefined, '#ingresses')); + } + + if (services.length) { + rows.unshift(useResourceCardRow(this.t('component.resource.detail.card.resourcesCard.rows.services'), services, undefined, undefined, '#services')); + } } return rows; diff --git a/shell/plugins/dashboard-store/resource-class.js b/shell/plugins/dashboard-store/resource-class.js index 49577c0b33d..c9e684a6571 100644 --- a/shell/plugins/dashboard-store/resource-class.js +++ b/shell/plugins/dashboard-store/resource-class.js @@ -507,6 +507,10 @@ export function colorForState(state, isError, isTransitioning) { return `text-${ color }`; } +export function simpleColorForState(state, isError, isTransitioning) { + return colorForState(state, isError, isTransitioning).replace('text-', '') || 'disabled'; +} + export function stateDisplay(state) { // @TODO use translations const key = (state || 'active').toLowerCase(); @@ -754,7 +758,7 @@ export default class Resource { } get stateSimpleColor() { - return this.stateColor.replace('text-', ''); + return simpleColorForState(this.state, this.stateObj?.error, this.stateObj?.transitioning); } get stateBackground() { @@ -2246,8 +2250,8 @@ export default class Resource { const rows = []; const relationships = this.metadata?.relationships || []; - const referredToByRels = relationships.filter((r) => r.fromType && !r.selector); - const refersToRels = relationships.filter((r) => r.toType && !r.selector && !r.fromType); + const referredToByRels = relationships.filter((r) => r.fromType && r.fromId && !r.selector); + const refersToRels = relationships.filter((r) => r.toType && r.toId && !r.selector && !r.fromType); if (referredToByRels.length) { rows.push(useResourceCardRowFromRelationships( From 5efc52cb422212087ae4d50be5185f0961c1a0d5 Mon Sep 17 00:00:00 2001 From: Cody Jackson Date: Thu, 21 May 2026 18:01:39 +0000 Subject: [PATCH 7/7] Add default parameter values to simpleColorForState Fixes TS2554 build error where callers passed only the state argument. Fixes #16375 --- shell/plugins/dashboard-store/resource-class.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shell/plugins/dashboard-store/resource-class.js b/shell/plugins/dashboard-store/resource-class.js index c9e684a6571..18056e1a77b 100644 --- a/shell/plugins/dashboard-store/resource-class.js +++ b/shell/plugins/dashboard-store/resource-class.js @@ -507,7 +507,7 @@ export function colorForState(state, isError, isTransitioning) { return `text-${ color }`; } -export function simpleColorForState(state, isError, isTransitioning) { +export function simpleColorForState(state, isError = false, isTransitioning = false) { return colorForState(state, isError, isTransitioning).replace('text-', '') || 'disabled'; }