xref: /openbmc/webui-vue/src/api/composables/useAllSubResources.ts (revision d3b05033fe82aed6c53f4f2b52c23d3bd285d423)
1import { useQuery, useQueryClient } from '@tanstack/vue-query';
2import { computed } from 'vue';
3import api from '@/store/api';
4import {
5  useRedfishRoot,
6  supportsExpandQuery,
7  supportsSelectQuery,
8} from './useRedfishRoot';
9import type { CollectionMember } from './useRedfishCollection';
10
11/**
12 * Generic Redfish resource with sub-resource collection
13 */
14interface ResourceWithCollection {
15  '@odata.id': string;
16  [key: string]: unknown;
17}
18
19/**
20 * Fetches all parent resources and checks which have the sub-resource
21 * Uses $select for efficiency if supported
22 *
23 * @param parentCollectionPath - Path to parent collection (e.g., '/redfish/v1/Chassis')
24 * @param subResourceName - Name of sub-resource (e.g., 'Sensors')
25 * @param canUseSelect - Whether BMC supports $select
26 * @returns Array of parent resource URIs that have the sub-resource
27 */
28async function discoverParentsWithSubResource(
29  parentCollectionPath: string,
30  subResourceName: string,
31  canUseSelect: boolean,
32): Promise<string[]> {
33  try {
34    const selectParam = canUseSelect ? `?$select=${subResourceName}` : '';
35    const { data } = await api.get(`${parentCollectionPath}${selectParam}`);
36
37    if (!data.Members || !Array.isArray(data.Members)) {
38      // Check if this is a single resource with the sub-resource
39      if (data['@odata.id'] && data[subResourceName]) {
40        return [data['@odata.id']];
41      }
42      return [];
43    }
44
45    if (
46      canUseSelect &&
47      data.Members.length > 0 &&
48      data.Members[0][subResourceName]
49    ) {
50      return data.Members.filter(
51        (member: ResourceWithCollection) => member[subResourceName],
52      ).map((member: ResourceWithCollection) => member['@odata.id']);
53    }
54
55    const checkPromises = data.Members.map(async (member: CollectionMember) => {
56      try {
57        const { data: parentData } = await api.get<ResourceWithCollection>(
58          member['@odata.id'],
59        );
60        return parentData[subResourceName] ? member['@odata.id'] : null;
61      } catch (error) {
62        console.error(
63          `Error checking ${member['@odata.id']} for ${subResourceName}:`,
64          error,
65        );
66        return null;
67      }
68    });
69
70    const results = await Promise.all(checkPromises);
71    const foundParents = results.filter(
72      (uri: String): uri is string => uri !== null,
73    );
74    return foundParents;
75  } catch (error) {
76    console.error(`Error discovering parents with ${subResourceName}:`, error);
77    return [];
78  }
79}
80
81/**
82 * Fetches sub-resources from a single parent
83 * Uses $expand if supported, falls back to individual fetches
84 *
85 * @param parentUri - Parent resource URI
86 * @param subResourceName - Name of sub-resource collection
87 * @param canExpand - Whether BMC supports $expand
88 * @param queryClient - TanStack Query client for incremental updates
89 * @param queryKey - Query key for cache updates
90 * @returns Array of sub-resources
91 */
92async function fetchSubResourcesFromParent<T>(
93  parentUri: string,
94  subResourceName: string,
95  canExpand: boolean,
96  queryClient: ReturnType<typeof useQueryClient>,
97  queryKey: unknown[],
98): Promise<T[]> {
99  const subResourcePath = `${parentUri}/${subResourceName}`;
100
101  try {
102    if (canExpand) {
103      const { data } = await api.get(`${subResourcePath}?$expand=.($levels=1)`);
104
105      if (data.Members && Array.isArray(data.Members)) {
106        queryClient.setQueryData(queryKey, (oldData: T[] = []) => [
107          ...oldData,
108          ...data.Members,
109        ]);
110        return data.Members;
111      }
112    }
113
114    const { data: collection } = await api.get(subResourcePath);
115
116    if (!collection.Members || !Array.isArray(collection.Members)) {
117      return [];
118    }
119
120    const memberPromises = collection.Members.map(
121      async (member: CollectionMember) => {
122        try {
123          const { data: memberData } = await api.get<T>(member['@odata.id']);
124          queryClient.setQueryData(queryKey, (oldData: T[] = []) => [
125            ...oldData,
126            memberData,
127          ]);
128          return memberData;
129        } catch (error) {
130          console.error(`Error fetching ${member['@odata.id']}:`, error);
131          return null;
132        }
133      },
134    );
135
136    const members = await Promise.all(memberPromises);
137    return members.filter((m: T | null): m is T => m !== null);
138  } catch (error) {
139    // Silently handle 404 - sub-resource may not exist on this parent
140    if (
141      (error as { response?: { status?: number } }).response?.status !== 404
142    ) {
143      console.error(
144        `Error fetching ${subResourceName} from ${parentUri}:`,
145        error,
146      );
147    }
148    return [];
149  }
150}
151
152/**
153 * Deduplicates resources by @odata.id
154 * Prevents duplicate entries when the same resource is referenced by multiple parents
155 *
156 * @param items - Array of resources to deduplicate
157 * @returns Deduplicated array
158 */
159function deduplicateByOdataId<T>(items: T[]): T[] {
160  if (items.length === 0) return items;
161
162  const seen = new Set<string>();
163  const deduplicated: T[] = [];
164
165  for (const item of items) {
166    const id = (item as any)['@odata.id'];
167    if (id) {
168      if (seen.has(id)) continue;
169      seen.add(id);
170    }
171    deduplicated.push(item);
172  }
173
174  return deduplicated;
175}
176
177/**
178 * Fetches all sub-resources from all parent resources
179 *
180 * @param parentUris - Array of parent resource URIs
181 * @param subResourceName - Name of sub-resource collection
182 * @param canExpand - Whether BMC supports $expand
183 * @param queryClient - TanStack Query client
184 * @param queryKey - Query key for cache updates
185 * @returns Array of all sub-resources (deduplicated by @odata.id)
186 * @throws Error if all requests fail and no data is retrieved
187 */
188async function fetchAllSubResources<T>(
189  parentUris: string[],
190  subResourceName: string,
191  canExpand: boolean,
192  queryClient: ReturnType<typeof useQueryClient>,
193  queryKey: unknown[],
194): Promise<T[]> {
195  queryClient.setQueryData(queryKey, []);
196
197  const fetchPromises = parentUris.map((parentUri) =>
198    fetchSubResourcesFromParent<T>(
199      parentUri,
200      subResourceName,
201      canExpand,
202      queryClient,
203      queryKey,
204    ).catch((error) => {
205      // Return empty array on failure but track the error
206      console.error(
207        `Failed to fetch ${subResourceName} from ${parentUri}:`,
208        error,
209      );
210      return [] as T[];
211    }),
212  );
213
214  const results = await Promise.all(fetchPromises);
215  const flattened = results.flat();
216
217  // Track failed requests (those that returned empty arrays)
218  const failed = results.filter((result) => result.length === 0);
219
220  // If all requests failed and we have no data, throw an error
221  if (flattened.length === 0 && failed.length > 0) {
222    const error = new Error(
223      `Failed to fetch ${subResourceName} collections (${failed.length}/${results.length} requests failed).`,
224    );
225    throw error;
226  }
227
228  // Deduplicate by @odata.id to prevent duplicate entries
229  // when the same resource is referenced by multiple parents
230  return deduplicateByOdataId(flattened);
231}
232
233/**
234 * Smart retry function that doesn't retry on 4xx client errors.
235 * 4xx errors (like 404 Not Found) won't succeed on retry.
236 *
237 * @param failureCount - Number of times the request has failed
238 * @param error - The error object from the failed request
239 * @returns Whether to retry the request
240 */
241function shouldRetry(failureCount: number, error: unknown): boolean {
242  const status = (error as { response?: { status?: number } })?.response
243    ?.status;
244  // Don't retry on 4xx client errors (bad request, not found, unauthorized, etc.)
245  if (status && status >= 400 && status < 500) {
246    return false;
247  }
248  // Retry up to 3 times for other errors (network issues, 5xx server errors)
249  return failureCount < 3;
250}
251
252/**
253 * Generic composable for fetching all sub-resources from a collection
254 *
255 * @param parentCollectionPath - Path to parent collection
256 * @param subResourceName - Name of sub-resource collection
257 * @returns TanStack Query result with all sub-resources
258 */
259export function useAllSubResources<T>(
260  parentCollectionPath: string,
261  subResourceName: string,
262) {
263  const queryClient = useQueryClient();
264  const { data: serviceRoot } = useRedfishRoot();
265
266  const canExpand = computed(() => supportsExpandQuery(serviceRoot.value));
267  const canSelect = computed(() => supportsSelectQuery(serviceRoot.value));
268
269  // Query key for this specific sub-resource fetch
270  const queryKey = [
271    'redfish',
272    'allSubResources',
273    parentCollectionPath,
274    subResourceName,
275  ];
276
277  return useQuery({
278    queryKey,
279    queryFn: async () => {
280      // Always perform discovery on each query execution
281      // This ensures fresh data and proper cache behavior
282      const parentUris = await discoverParentsWithSubResource(
283        parentCollectionPath,
284        subResourceName,
285        canSelect.value,
286      );
287
288      return fetchAllSubResources<T>(
289        parentUris,
290        subResourceName,
291        canExpand.value,
292        queryClient,
293        queryKey,
294      );
295    },
296    enabled: computed(() => !!serviceRoot.value),
297    staleTime: Infinity, // Data never becomes stale automatically
298    gcTime: 300000, // Keep in cache for 5 minutes after component unmount
299    refetchOnMount: false, // Don't refetch when component remounts
300    refetchOnWindowFocus: false, // Don't refetch when window regains focus
301    refetchOnReconnect: false, // Don't refetch on network reconnect
302    placeholderData: (prev) => prev,
303    retry: shouldRetry,
304    retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
305  });
306}
307