Skip to content

Commit

Permalink
Merge pull request #2486 from headlamp-k8s/use-lists-queries
Browse files Browse the repository at this point in the history
frontend: Use multiple queries in useKubeObjectList and add support for allowedNamespaces
  • Loading branch information
illume authored Nov 1, 2024
2 parents cba5d81 + 6b4d7b8 commit 2596766
Show file tree
Hide file tree
Showing 9 changed files with 581 additions and 284 deletions.
8 changes: 4 additions & 4 deletions frontend/src/helpers/testHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useKubeObjectList } from '../lib/k8s/api/v2/hooks';
import { KubeObject } from '../lib/k8s/KubeObject';

export const useMockListQuery = {
noData: () =>
Expand All @@ -10,7 +10,7 @@ export const useMockListQuery = {
yield null;
yield null;
},
} as any as typeof useKubeObjectList),
} as any as typeof KubeObject.useList),
error: () =>
({
data: null,
Expand All @@ -20,7 +20,7 @@ export const useMockListQuery = {
yield null;
yield 'Phony error is phony!';
},
} as any as typeof useKubeObjectList),
} as any as typeof KubeObject.useList),
data: (items: any[]) =>
(() => ({
data: { kind: 'List', items },
Expand All @@ -30,5 +30,5 @@ export const useMockListQuery = {
yield items;
yield null;
},
})) as any as typeof useKubeObjectList,
})) as any as typeof KubeObject.useList,
};
56 changes: 40 additions & 16 deletions frontend/src/lib/k8s/KubeObject.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import { OpPatch } from 'json-patch';
import { JSONPath } from 'jsonpath-plus';
import { cloneDeep, unset } from 'lodash';
import React from 'react';
import helpers from '../../helpers';
import React, { useMemo } from 'react';
import exportFunctions from '../../helpers';
import { getCluster } from '../cluster';
import { createRouteURL } from '../router';
import { timeAgo } from '../util';
import { useClusterGroup, useConnectApi } from '.';
import { useKubeObject, useKubeObjectList } from './api/v2/hooks';
import { useKubeObject } from './api/v2/hooks';
import { makeListRequests, useKubeObjectList } from './api/v2/useKubeObjectList';
import { ApiError, apiFactory, apiFactoryWithNamespace, post, QueryParameters } from './apiProxy';
import { KubeEvent } from './event';
import { KubeMetadata } from './KubeMetadata';

function getAllowedNamespaces() {
const cluster = getCluster();
function getAllowedNamespaces(cluster: string | null = getCluster()): string[] {
if (!cluster) {
return [];
}

const clusterSettings = helpers.loadClusterSettings(cluster);
const clusterSettings = exportFunctions.loadClusterSettings(cluster);
return clusterSettings.allowedNamespaces || [];
}

Expand Down Expand Up @@ -286,24 +286,48 @@ export class KubeObject<T extends KubeObjectInterface | KubeEvent = any> {
}

static useList<K extends KubeObject>(
this: new (...args: any) => K,
this: (new (...args: any) => K) & typeof KubeObject<any>,
{
cluster,
clusters,
namespace,
...queryParams
}: { cluster?: string; namespace?: string; clusters?: string[] } & QueryParameters = {}
}: {
cluster?: string;
clusters?: string[];
namespace?: string | string[];
} & QueryParameters = {}
) {
const clusterGroup = useClusterGroup();
const theClusters = clusters || clusterGroup;

return useKubeObjectList<K>({
const fallbackClusters = useClusterGroup();

// Create requests for each cluster and namespace
const requests = useMemo(() => {
const clusterList = cluster
? [cluster]
: clusters || (fallbackClusters.length === 0 ? [''] : fallbackClusters);

const namespacesFromParams =
typeof namespace === 'string'
? [namespace]
: Array.isArray(namespace)
? namespace
: undefined;

return makeListRequests(
clusterList,
getAllowedNamespaces,
this.isNamespaced,
namespacesFromParams
);
}, [cluster, clusters, fallbackClusters, namespace, this.isNamespaced]);

const result = useKubeObjectList<K>({
queryParams: queryParams,
kubeObjectClass: this as (new (...args: any) => K) & typeof KubeObject<any>,
clusters: theClusters,
cluster: cluster,
namespace: namespace,
kubeObjectClass: this,
requests,
});

return result;
}

static useGet<K extends KubeObject>(
Expand Down
229 changes: 15 additions & 214 deletions frontend/src/lib/k8s/api/v2/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getCluster } from '../../../cluster';
import { ApiError, QueryParameters } from '../../apiProxy';
import { KubeObject, KubeObjectInterface } from '../../KubeObject';
import { clusterFetch } from './fetch';
import { KubeList, KubeListUpdateEvent } from './KubeList';
import { KubeListUpdateEvent } from './KubeList';
import { KubeObjectEndpoint } from './KubeObjectEndpoint';
import { makeUrl } from './makeUrl';
import { useWebSocket } from './webSocket';
Expand Down Expand Up @@ -159,9 +159,13 @@ export function useKubeObject<K extends KubeObject>({
* @throws Error
* When no endpoints are working
*/
const getWorkingEndpoint = async (endpoints: KubeObjectEndpoint[], cluster: string) => {
const getWorkingEndpoint = async (
endpoints: KubeObjectEndpoint[],
cluster: string,
namespace?: string
) => {
const promises = endpoints.map(endpoint => {
return clusterFetch(KubeObjectEndpoint.toUrl(endpoint), {
return clusterFetch(KubeObjectEndpoint.toUrl(endpoint, namespace), {
method: 'GET',
cluster: cluster ?? getCluster() ?? '',
}).then(it => {
Expand All @@ -179,225 +183,22 @@ const getWorkingEndpoint = async (endpoints: KubeObjectEndpoint[], cluster: stri
*
* @params endpoints - List of possible endpoints
*/
const useEndpoints = (endpoints: KubeObjectEndpoint[], cluster: string) => {
export const useEndpoints = (
endpoints: KubeObjectEndpoint[],
cluster?: string,
namespace?: string
) => {
const { data: endpoint } = useQuery({
enabled: endpoints.length > 1,
enabled: endpoints.length > 1 && cluster !== undefined,
queryKey: ['endpoints', endpoints],
queryFn: () =>
getWorkingEndpoint(endpoints, cluster)
getWorkingEndpoint(endpoints, cluster!, namespace)
.then(endpoints => endpoints)
.catch(() => null),
});

if (cluster === null || cluster === undefined) return undefined;
if (endpoints.length === 1) return endpoints[0];

return endpoint;
};

/**
* Returns a list of Kubernetes objects and watches for changes
*
* @private please use useKubeObjectList.
*/
function _useKubeObjectList<K extends KubeObject>({
kubeObjectClass,
namespace,
cluster: maybeCluster,
queryParams,
}: {
/** Class to instantiate the object with */
kubeObjectClass: (new (...args: any) => K) & typeof KubeObject<any>;
/** Object list namespace */
namespace?: string;
/** Object list cluster */
cluster?: string;
queryParams?: QueryParameters;
}): [Array<K> | null, ApiError | null] & QueryListResponse<KubeList<K>, K, ApiError> {
const cluster = maybeCluster ?? getCluster() ?? '';
const endpoint = useEndpoints(kubeObjectClass.apiEndpoint.apiInfo, cluster);

const cleanedUpQueryParams = Object.fromEntries(
Object.entries(queryParams ?? {}).filter(([, value]) => value !== undefined && value !== '')
);

const queryKey = useMemo(
() => ['list', cluster, endpoint, namespace ?? '', cleanedUpQueryParams],
[endpoint, namespace, cleanedUpQueryParams]
);

const client = useQueryClient();
const query = useQuery<KubeList<any> | null | undefined, ApiError>({
enabled: !!endpoint,
placeholderData: null,
queryKey,
queryFn: async () => {
if (!endpoint) return;
const list: KubeList<any> = await clusterFetch(
makeUrl([KubeObjectEndpoint.toUrl(endpoint!, namespace)], cleanedUpQueryParams),
{
cluster,
}
).then(it => it.json());
list.items = list.items.map(item => {
const itm = new kubeObjectClass({ ...item, kind: list.kind.replace('List', '') });
itm.cluster = cluster;
return itm;
});

return list;
},
});

const items: Array<K> | null = query.error ? null : query.data?.items ?? null;
const data: KubeList<K> | null = query.error ? null : query.data ?? null;

useWebSocket<KubeListUpdateEvent<K>>({
url: () =>
makeUrl([KubeObjectEndpoint.toUrl(endpoint!)], {
...cleanedUpQueryParams,
watch: 1,
resourceVersion: data!.metadata.resourceVersion,
}),
cluster,
enabled: !!endpoint && !!data,
onMessage(update) {
client.setQueryData(queryKey, (oldList: any) => {
const newList = KubeList.applyUpdate(oldList, update, kubeObjectClass);
return newList;
});
},
});

// @ts-ignore
return {
items,
data,
error: query.error,
isError: query.isError,
isLoading: query.isLoading,
isFetching: query.isFetching,
isSuccess: query.isSuccess,
status: query.status,
*[Symbol.iterator](): ArrayIterator<ApiError | K[] | null> {
yield items;
yield query.error;
},
};
}

/**
* Returns a combined list of Kubernetes objects and watches for changes from the clusters given.
*/
export function useKubeObjectList<K extends KubeObject>({
kubeObjectClass,
namespace,
cluster,
clusters,
queryParams,
}: {
/** Class to instantiate the object with */
kubeObjectClass: (new (...args: any) => K) & typeof KubeObject<any>;
/** Object list namespace */
namespace?: string;
cluster?: string;
/** Object list clusters */
clusters?: string[];
queryParams?: QueryParameters;
}): [Array<K> | null, ApiError | null] & QueryListResponse<KubeList<K>, K, ApiError> {
if (clusters && clusters.length > 0) {
return _useKubeObjectLists({
kubeObjectClass,
namespace,
clusters: clusters,
queryParams,
});
} else {
return _useKubeObjectList({
kubeObjectClass,
namespace,
cluster: cluster,
queryParams,
});
}
}

/**
* Returns a combined list of Kubernetes objects and watches for changes from the clusters given.
*
* @private please use useKubeObjectList
*/
function _useKubeObjectLists<K extends KubeObject>({
kubeObjectClass,
namespace,
clusters,
queryParams,
}: {
/** Class to instantiate the object with */
kubeObjectClass: (new (...args: any) => K) & typeof KubeObject<any>;
/** Object list namespace */
namespace?: string;
/** Object list clusters */
clusters: string[];
queryParams?: QueryParameters;
}): [Array<K> | null, ApiError | null] & QueryListResponse<KubeList<K>, K, ApiError> {
const clusterResults: Record<string, ReturnType<typeof useKubeObjectList<K>>> = {};

for (const cluster of clusters) {
clusterResults[cluster] = _useKubeObjectList({
kubeObjectClass,
namespace,
cluster: cluster || undefined,
queryParams,
});
}

let items = null;
for (const cluster of clusters) {
if (items === null) {
items = clusterResults[cluster].items;
} else {
items = items.concat(clusterResults[cluster].items ?? []);
}
}

// data makes no sense really for multiple clusters, but useful for single cluster?
const data =
clusters.map(cluster => clusterResults[cluster].data).find(it => it !== null) ?? null;
const error =
clusters.map(cluster => clusterResults[cluster].error).find(it => it !== null) ?? null;
const isError = clusters.some(cluster => clusterResults[cluster].isError);
const isLoading = clusters.some(cluster => clusterResults[cluster].isLoading);
const isFetching = clusters.some(cluster => clusterResults[cluster].isFetching);
const isSuccess = clusters.every(cluster => clusterResults[cluster].isSuccess);
// status makes no sense really for multiple clusters, but maybe useful for single cluster?
const status =
clusters.map(cluster => clusterResults[cluster].status).find(it => it !== null) ?? 'pending';

let clusterErrors: Record<string, ApiError | null> | null = {};
clusters.forEach(cluster => {
if (clusterErrors && clusterResults[cluster]?.error !== null) {
clusterErrors[cluster] = clusterResults[cluster].error;
}
});
if (Object.keys(clusterErrors).length === 0) {
clusterErrors = null;
}

// @ts-ignore
return {
items,
data,
error,
isError,
isLoading,
isFetching,
isSuccess,
status,
*[Symbol.iterator](): ArrayIterator<ApiError | K[] | null> {
yield items;
yield error;
},
clusterResults,
clusterErrors,
};
}
6 changes: 6 additions & 0 deletions frontend/src/lib/k8s/api/v2/makeUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,10 @@ describe('makeUrl', () => {
const result = makeUrl(urlParts);
expect(result).toBe('http://example.com/123/true/resource');
});

it('should create a url from a single string', () => {
expect(makeUrl('http://example.com/some/path', { watch: 1 })).toBe(
'http://example.com/some/path?watch=1'
);
});
});
Loading

0 comments on commit 2596766

Please sign in to comment.