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';
}