Skip to content

Commit 0afe672

Browse files
committed
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
1 parent d70abc9 commit 0afe672

11 files changed

Lines changed: 1127 additions & 231 deletions

File tree

shell/components/Resource/Detail/Card/StateCard/__tests__/composables.test.ts

Lines changed: 194 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { useResourceCardRow } from '@shell/components/Resource/Detail/Card/StateCard/composables';
1+
import { useResourceCardRow, useResourceCardRowFromSummary, useResourceCardRowFromRelationships } from '@shell/components/Resource/Detail/Card/StateCard/composables';
2+
import type { SummaryResult } from '@shell/components/Resource/Detail/Card/StateCard/composables';
23

34
describe('useResourceCardRow', () => {
45
describe('with default keys', () => {
@@ -140,3 +141,195 @@ describe('useResourceCardRow', () => {
140141
});
141142
});
142143
});
144+
145+
describe('useResourceCardRowFromSummary', () => {
146+
it('should return empty props when summaryResult is null', () => {
147+
const result = useResourceCardRowFromSummary('Services', null);
148+
149+
expect(result.label).toBe('Services');
150+
expect(result.color).toBeUndefined();
151+
expect(result.counts).toBeUndefined();
152+
});
153+
154+
it('should return empty props when summary array is empty', () => {
155+
const result = useResourceCardRowFromSummary('Services', { count: 0, summary: [] });
156+
157+
expect(result.color).toBeUndefined();
158+
expect(result.counts).toBeUndefined();
159+
});
160+
161+
it('should return empty props when summary is null', () => {
162+
const result = useResourceCardRowFromSummary('Services', { count: 5, summary: null });
163+
164+
expect(result.color).toBeUndefined();
165+
expect(result.counts).toBeUndefined();
166+
});
167+
168+
it('should return a plain count when no metadata.state.name entry exists', () => {
169+
const summary: SummaryResult = {
170+
count: 3,
171+
summary: [{ property: 'some.other.field', counts: { foo: 3 } }]
172+
};
173+
174+
const result = useResourceCardRowFromSummary('Pods', summary);
175+
176+
expect(result.counts).toStrictEqual([{ label: '', count: 3 }]);
177+
});
178+
179+
it('should map state names to colors and display labels', () => {
180+
const summary: SummaryResult = {
181+
count: 5,
182+
summary: [{ property: 'metadata.state.name', counts: { running: 3, error: 2 } }]
183+
};
184+
185+
const result = useResourceCardRowFromSummary('Pods', summary);
186+
187+
expect(result.counts).toHaveLength(2);
188+
expect(result.counts).toContainEqual(expect.objectContaining({
189+
label: 'running', count: 3, color: 'success'
190+
}));
191+
expect(result.counts).toContainEqual(expect.objectContaining({
192+
label: 'error', count: 2, color: 'error'
193+
}));
194+
});
195+
196+
it('should set the highest alert color as main color', () => {
197+
const summary: SummaryResult = {
198+
count: 4,
199+
summary: [{ property: 'metadata.state.name', counts: { active: 3, error: 1 } }]
200+
};
201+
202+
const result = useResourceCardRowFromSummary('Services', summary);
203+
204+
expect(result.color).toBe('error');
205+
});
206+
207+
it('should sort by alert level then by count', () => {
208+
const summary: SummaryResult = {
209+
count: 6,
210+
summary: [{
211+
property: 'metadata.state.name',
212+
counts: {
213+
active: 3, error: 1, warning: 2
214+
}
215+
}]
216+
};
217+
218+
const result = useResourceCardRowFromSummary('Pods', summary);
219+
220+
expect(result.counts![0].color).toBe('error');
221+
expect(result.counts![1].color).toBe('warning');
222+
expect(result.counts![2].color).toBe('success');
223+
});
224+
225+
it('should pass the to parameter through', () => {
226+
const to = { hash: '#pods' };
227+
const result = useResourceCardRowFromSummary('Pods', null, to);
228+
229+
expect(result.to).toStrictEqual(to);
230+
});
231+
232+
it('should handle a single state', () => {
233+
const summary: SummaryResult = {
234+
count: 2,
235+
summary: [{ property: 'metadata.state.name', counts: { active: 2 } }]
236+
};
237+
238+
const result = useResourceCardRowFromSummary('Services', summary);
239+
240+
expect(result.counts).toHaveLength(1);
241+
expect(result.counts![0]).toStrictEqual(expect.objectContaining({
242+
label: 'active', count: 2, color: 'success'
243+
}));
244+
expect(result.color).toBe('success');
245+
});
246+
});
247+
248+
describe('useResourceCardRowFromRelationships', () => {
249+
it('should return empty props for empty relationships', () => {
250+
const result = useResourceCardRowFromRelationships('Refers to', []);
251+
252+
expect(result.label).toBe('Refers to');
253+
expect(result.color).toBeUndefined();
254+
expect(result.counts).toBeUndefined();
255+
});
256+
257+
it('should aggregate relationship states', () => {
258+
const rels = [
259+
{ toType: 'configmap', state: 'active' },
260+
{ toType: 'secret', state: 'active' },
261+
{ toType: 'serviceaccount', state: 'error' }
262+
];
263+
264+
const result = useResourceCardRowFromRelationships('Refers to', rels);
265+
266+
expect(result.counts).toHaveLength(2);
267+
expect(result.counts).toContainEqual(expect.objectContaining({ label: 'active', count: 2 }));
268+
expect(result.counts).toContainEqual(expect.objectContaining({ label: 'error', count: 1 }));
269+
});
270+
271+
it('should default missing state to "missing"', () => {
272+
const rels = [
273+
{ toType: 'configmap' },
274+
{ toType: 'secret', state: 'active' }
275+
];
276+
277+
const result = useResourceCardRowFromRelationships('Refers to', rels);
278+
279+
expect(result.counts).toContainEqual(expect.objectContaining({
280+
label: 'missing', count: 1, color: 'warning'
281+
}));
282+
expect(result.counts).toContainEqual(expect.objectContaining({
283+
label: 'active', count: 1, color: 'success'
284+
}));
285+
});
286+
287+
it('should set the highest alert color as main color', () => {
288+
const rels = [
289+
{ toType: 'configmap', state: 'active' },
290+
{ toType: 'secret', state: 'error' }
291+
];
292+
293+
const result = useResourceCardRowFromRelationships('Refers to', rels);
294+
295+
expect(result.color).toBe('error');
296+
});
297+
298+
it('should sort by alert level then by count', () => {
299+
const rels = [
300+
{ toType: 'a', state: 'active' },
301+
{ toType: 'b', state: 'active' },
302+
{ toType: 'c', state: 'active' },
303+
{ toType: 'd', state: 'error' },
304+
{ toType: 'e', state: 'warning' },
305+
{ toType: 'f', state: 'warning' }
306+
];
307+
308+
const result = useResourceCardRowFromRelationships('Refers to', rels);
309+
310+
expect(result.counts![0].color).toBe('error');
311+
expect(result.counts![1].color).toBe('warning');
312+
expect(result.counts![2].color).toBe('success');
313+
});
314+
315+
it('should pass the to parameter through', () => {
316+
const to = { hash: '#related' };
317+
const result = useResourceCardRowFromRelationships('Refers to', [], to);
318+
319+
expect(result.to).toStrictEqual(to);
320+
});
321+
322+
it('should handle all relationships having no state', () => {
323+
const rels = [
324+
{ toType: 'configmap' },
325+
{ toType: 'secret' }
326+
];
327+
328+
const result = useResourceCardRowFromRelationships('Refers to', rels);
329+
330+
expect(result.counts).toHaveLength(1);
331+
expect(result.counts![0]).toStrictEqual(expect.objectContaining({
332+
label: 'missing', count: 2, color: 'warning'
333+
}));
334+
});
335+
});

shell/components/Resource/Detail/Card/StateCard/composables.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ import { computed, Ref, toValue } from 'vue';
66
import { useStore } from 'vuex';
77
import { Props as StateCardProps } from '@shell/components/Resource/Detail/Card/StateCard/types';
88
import { RouteLocationRaw } from 'vue-router';
9+
import { colorForState as colorForStateFn, stateDisplay as stateDisplayFn } from '@shell/plugins/dashboard-store/resource-class';
10+
11+
export interface SummaryResult {
12+
count: number;
13+
summary: { property: string; counts: Record<string, number> }[] | null;
14+
}
915

1016
export function useResourceCardRow(label: string, resources: any[], stateColorKey = 'stateSimpleColor', stateDisplayKey = 'stateDisplay', to?: RouteLocationRaw): ResourceRowProps {
1117
const agg: any = {};
@@ -49,6 +55,122 @@ export function useResourceCardRow(label: string, resources: any[], stateColorKe
4955
};
5056
}
5157

58+
/**
59+
* Builds a ResourceRowProps from summary API response data.
60+
* The summary API returns state counts as { property: 'metadata.state.name', counts: { running: 2, error: 1 } }.
61+
* This maps those state names to display labels and colors using the same logic as resource models.
62+
*/
63+
export function useResourceCardRowFromSummary(label: string, summaryResult: SummaryResult | null | undefined, to?: RouteLocationRaw): ResourceRowProps {
64+
if (!summaryResult?.summary?.length) {
65+
return {
66+
label,
67+
color: undefined,
68+
counts: undefined,
69+
to
70+
};
71+
}
72+
73+
const stateSummary = summaryResult.summary.find((s) => s.property === 'metadata.state.name');
74+
75+
if (!stateSummary?.counts) {
76+
return {
77+
label,
78+
color: undefined,
79+
counts: summaryResult.count ? [{ label: '', count: summaryResult.count }] : undefined,
80+
to
81+
};
82+
}
83+
84+
interface Tuple extends Count {
85+
color: StateColor;
86+
}
87+
88+
const tuples: Tuple[] = Object.entries(stateSummary.counts).map(([state, count]) => {
89+
const colorRaw = colorForStateFn(state) as string;
90+
const color = (colorRaw?.replace('text-', '') || 'disabled') as StateColor;
91+
const display = stateDisplayFn(state) as string;
92+
93+
return {
94+
color,
95+
label: display?.toLowerCase() || state,
96+
count
97+
};
98+
});
99+
100+
tuples.sort((left: Tuple, right: Tuple) => {
101+
if (isHigherAlert(left.color, right.color)) {
102+
return -1;
103+
}
104+
105+
if (left.color !== right.color) {
106+
return 1;
107+
}
108+
109+
if (left.count === right.count) {
110+
return 0;
111+
}
112+
113+
return left.count > right.count ? -1 : 1;
114+
});
115+
116+
return {
117+
label,
118+
color: tuples.length ? tuples[0].color : undefined,
119+
counts: tuples.length ? tuples : undefined,
120+
to
121+
};
122+
}
123+
124+
/**
125+
* Builds a ResourceRowProps from relationship state data.
126+
* Each relationship entry includes a `state` field (e.g. "active", "error").
127+
*/
128+
export function useResourceCardRowFromRelationships(label: string, relationships: any[], to?: RouteLocationRaw): ResourceRowProps {
129+
if (!relationships.length) {
130+
return {
131+
label, color: undefined, counts: undefined, to
132+
};
133+
}
134+
135+
const agg: Record<string, { color: StateColor; label: string; count: number }> = {};
136+
137+
relationships.forEach((r: any) => {
138+
const state = (r.state || 'missing').toLowerCase();
139+
const colorRaw = colorForStateFn(state) as string;
140+
const color = (colorRaw?.replace('text-', '') || 'disabled') as StateColor;
141+
const display = (stateDisplayFn(state) as string)?.toLowerCase() || state;
142+
143+
agg[state] = agg[state] || {
144+
color, label: display, count: 0
145+
};
146+
agg[state].count++;
147+
});
148+
149+
interface Tuple extends Count {
150+
color: StateColor;
151+
}
152+
const tuples: Tuple[] = Object.values(agg);
153+
154+
tuples.sort((left: Tuple, right: Tuple) => {
155+
if (isHigherAlert(left.color, right.color)) {
156+
return -1;
157+
}
158+
159+
if (left.color !== right.color) {
160+
return 1;
161+
}
162+
163+
return left.count >= right.count ? -1 : 1;
164+
});
165+
166+
return {
167+
label,
168+
color: tuples.length ? tuples[0].color : undefined,
169+
counts: tuples.length ? tuples : undefined,
170+
to
171+
};
172+
}
173+
52174
export interface Pairs {
53175
label: string;
54176
to?: RouteLocationRaw;

0 commit comments

Comments
 (0)