- 1-1 of 1
+ 1-2 of 2
],
+ parameters: {
+ msw: {
+ handlers: {
+ storyBase: [
+ http.get(
+ 'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions',
+ () => HttpResponse.error()
+ ),
+ http.get(
+ 'http://localhost:4466/apis/apiextensions.k8s.io/v1/customresourcedefinitions',
+ () =>
+ HttpResponse.json({
+ kind: 'List',
+ metadata: {},
+ items: [mockCRD],
+ })
+ ),
+ http.get(
+ 'http://localhost:4466/apis/apiextensions.k8s.io/v1/customresourcedefinitions/mydefinition.phonyresources.io',
+ () => HttpResponse.json(mockCRD)
+ ),
+ http.get('http://localhost:4466/apis/my.phonyresources.io/v1/mycustomresources', () =>
+ HttpResponse.json({
+ kind: 'List',
+ metadata: {},
+ items: mockCRList,
+ })
+ ),
+ ],
+ },
+ },
+ },
} as Meta;
const Template: StoryFn = args => {
- const { useGet, useList, useApiGet, useApiList, name, namespace, viewType = 'list' } = args;
+ const { name, namespace, viewType = 'list' } = args;
const routerMap: TestContextProps['routerMap'] = {};
- overrideKubeObject(CustomResourceDefinition, {
- useApiGet,
- useApiList,
- useGet,
- useList,
- });
-
if (!!name) {
routerMap['name'] = name;
}
@@ -48,13 +68,10 @@ const Template: StoryFn = args => {
};
export const List = Template.bind({});
-List.args = {
- useList: CRDMockMethods.usePhonyList,
-};
+List.args = {};
export const Details = Template.bind({});
Details.args = {
- useApiGet: CRDMockMethods.usePhonyApiGet,
viewType: 'details',
name: 'mydefinition.phonyresources.io',
};
diff --git a/frontend/src/components/crd/CustomResourceDetails.stories.tsx b/frontend/src/components/crd/CustomResourceDetails.stories.tsx
index 9df5f9c18e5..751cf9cbec2 100644
--- a/frontend/src/components/crd/CustomResourceDetails.stories.tsx
+++ b/frontend/src/components/crd/CustomResourceDetails.stories.tsx
@@ -1,12 +1,8 @@
import { Meta, StoryFn } from '@storybook/react';
import { http, HttpResponse } from 'msw';
-import { ResourceClasses } from '../../lib/k8s';
import { TestContext } from '../../test';
import { CustomResourceDetails, CustomResourceDetailsProps } from './CustomResourceDetails';
-import { CRMockClass } from './storyHelper';
-
-// So we can test with a mocked CR.
-ResourceClasses['mycustomresources'] = CRMockClass;
+import { mockCRD, mockCRList } from './storyHelper';
export default {
title: 'crd/CustomResourceDetails',
@@ -18,19 +14,7 @@ export default {
storyBase: [
http.get(
'http://localhost:4466/apis/my.phonyresources.io/v1/namespaces/mynamespace/mycustomresources/mycustomresource',
- () =>
- HttpResponse.json({
- kind: 'MyCustomResource',
- apiVersion: 'my.phonyresources.io/v1',
- metadata: {
- name: 'mycustomresource',
- uid: 'phony2',
- creationTimestamp: new Date('2021-12-15T14:57:13Z').toString(),
- resourceVersion: '1',
- namespace: 'mynamespace',
- selfLink: '1',
- },
- })
+ () => HttpResponse.json(mockCRList[0])
),
http.get('http://localhost:4466/apis/my.phonyresources.io/v1/mycustomresources', () =>
HttpResponse.json({})
@@ -45,11 +29,15 @@ export default {
),
http.get(
'http://localhost:4466/apis/apiextensions.k8s.io/v1/customresourcedefinitions/mydefinition.phonyresources.io',
- () => HttpResponse.error()
+ () => HttpResponse.json(mockCRD)
),
http.get('http://localhost:4466/api/v1/namespaces/mynamespace/events', () =>
HttpResponse.error()
),
+ http.get(
+ 'http://localhost:4466/apis/apiextensions.k8s.io/v1/customresourcedefinitions/loadingcrd',
+ () => HttpResponse.json(null)
+ ),
],
},
},
@@ -74,13 +62,6 @@ NoError.args = {
namespace: 'mynamespace',
};
-export const LoadingCRD = Template.bind({});
-LoadingCRD.args = {
- crName: 'loadingcr',
- crd: 'loadingcrd',
- namespace: '-',
-};
-
export const ErrorGettingCRD = Template.bind({});
ErrorGettingCRD.args = {
crName: 'doesnotmatter',
diff --git a/frontend/src/components/crd/CustomResourceList.stories.tsx b/frontend/src/components/crd/CustomResourceList.stories.tsx
index d13f82c8982..a046c77c985 100644
--- a/frontend/src/components/crd/CustomResourceList.stories.tsx
+++ b/frontend/src/components/crd/CustomResourceList.stories.tsx
@@ -1,12 +1,13 @@
import { Meta, StoryFn } from '@storybook/react';
+import { http, HttpResponse } from 'msw';
import { KubeObjectClass } from '../../lib/k8s/cluster';
-import CustomResourceDefinition from '../../lib/k8s/crd';
-import { overrideKubeObject, TestContext, TestContextProps } from '../../test';
+import { TestContext, TestContextProps } from '../../test';
import CustomResourceList from './CustomResourceList';
-import { CRDMockMethods } from './storyHelper';
+import { mockCRD, mockCRList } from './storyHelper';
interface MockerStory {
useApiGet?: KubeObjectClass['useApiGet'];
+ useGet?: KubeObjectClass['useGet'];
routerParams?: TestContextProps['routerMap'];
}
@@ -14,16 +15,44 @@ export default {
title: 'crd/CustomResourceList',
argTypes: {},
decorators: [Story => ],
+ parameters: {
+ msw: {
+ handlers: {
+ storyBase: [
+ http.get('http://localhost:4466/apis/my.phonyresources.io/v1/mycustomresources', () =>
+ HttpResponse.json({
+ kind: 'List',
+ metadata: {},
+ items: mockCRList,
+ })
+ ),
+ http.get(
+ 'http://localhost:4466/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions',
+ () => HttpResponse.error()
+ ),
+ http.get(
+ 'http://localhost:4466/apis/apiextensions.k8s.io/v1/customresourcedefinitions',
+ () =>
+ HttpResponse.json({
+ kind: 'List',
+ metadata: {},
+ items: [mockCRD],
+ })
+ ),
+ http.get(
+ 'http://localhost:4466/apis/apiextensions.k8s.io/v1/customresourcedefinitions/mydefinition.phonyresources.io',
+ () => HttpResponse.json(mockCRD)
+ ),
+ ],
+ },
+ },
+ },
} as Meta;
const Template: StoryFn = args => {
- const { useApiGet, routerParams = {} } = args;
+ const { routerParams = {} } = args;
const routerMap: TestContextProps['routerMap'] = routerParams;
- overrideKubeObject(CustomResourceDefinition, {
- useApiGet,
- });
-
return (
@@ -33,7 +62,6 @@ const Template: StoryFn = args => {
export const List = Template.bind({});
List.args = {
- useApiGet: CRDMockMethods.usePhonyApiGet,
routerParams: {
crd: 'mydefinition.phonyresources.io',
},
diff --git a/frontend/src/components/crd/__snapshots__/CustomResourceDetails.ErrorGettingCR.stories.storyshot b/frontend/src/components/crd/__snapshots__/CustomResourceDetails.ErrorGettingCR.stories.storyshot
index 8c99159c567..8e1fb79c130 100644
--- a/frontend/src/components/crd/__snapshots__/CustomResourceDetails.ErrorGettingCR.stories.storyshot
+++ b/frontend/src/components/crd/__snapshots__/CustomResourceDetails.ErrorGettingCR.stories.storyshot
@@ -6,7 +6,7 @@
- Error getting custom resource nonexistentcustomresource: No mock custom resource for you
+ Error getting custom resource nonexistentcustomresource: Unreachable
diff --git a/frontend/src/components/crd/__snapshots__/CustomResourceDetails.ErrorGettingCRD.stories.storyshot b/frontend/src/components/crd/__snapshots__/CustomResourceDetails.ErrorGettingCRD.stories.storyshot
index 32c3fe3a2fc..8834f51db5b 100644
--- a/frontend/src/components/crd/__snapshots__/CustomResourceDetails.ErrorGettingCRD.stories.storyshot
+++ b/frontend/src/components/crd/__snapshots__/CustomResourceDetails.ErrorGettingCRD.stories.storyshot
@@ -6,7 +6,7 @@
- Error getting custom resource definition error.crd.io: No mock CRD for you
+ Error getting custom resource definition error.crd.io: Unreachable
diff --git a/frontend/src/components/crd/storyHelper.ts b/frontend/src/components/crd/storyHelper.ts
index 0ad1c6ea43a..a4bef41bac9 100644
--- a/frontend/src/components/crd/storyHelper.ts
+++ b/frontend/src/components/crd/storyHelper.ts
@@ -1,52 +1,44 @@
-import React from 'react';
-import { ApiError, apiFactoryWithNamespace } from '../../lib/k8s/apiProxy';
-import { KubeObject, makeKubeObject } from '../../lib/k8s/cluster';
-import CustomResourceDefinition, { KubeCRD } from '../../lib/k8s/crd';
-
-const mockCRDMap: { [crdName: string]: KubeCRD | null } = {
- 'mydefinition.phonyresources.io': {
- kind: 'CustomResourceDefinition',
- apiVersion: 'apiextensions.k8s.io',
- metadata: {
- name: 'mydefinition.phonyresources.io',
- uid: 'phony',
- creationTimestamp: new Date('2021-12-15T14:57:13Z').toString(),
- resourceVersion: '1',
- selfLink: '1',
+export const mockCRD = {
+ kind: 'CustomResourceDefinition',
+ apiVersion: 'apiextensions.k8s.io',
+ metadata: {
+ name: 'mydefinition.phonyresources.io',
+ uid: 'phony',
+ creationTimestamp: new Date('2021-12-15T14:57:13Z').toString(),
+ resourceVersion: '1',
+ selfLink: '1',
+ },
+ spec: {
+ group: 'my.phonyresources.io',
+ version: 'v1',
+ names: {
+ plural: 'mycustomresources',
+ singular: 'mycustomresource',
+ kind: 'MyCustomResource',
+ listKind: 'MyCustomResourceList',
+ categories: ['all', 'category1'],
},
- spec: {
- group: 'my.phonyresources.io',
- version: 'v1',
- names: {
- plural: 'mycustomresources',
- singular: 'mycustomresource',
- kind: 'MyCustomResource',
- listKind: 'MyCustomResourceList',
- categories: ['all', 'category1'],
+ versions: [
+ {
+ name: 'v1',
+ served: false,
+ storage: false,
+ additionalPrinterColumns: [
+ {
+ name: 'Test Col',
+ type: 'string',
+ jsonPath: '.metadata.name',
+ description: 'My description',
+ },
+ ],
},
- versions: [
- {
- name: 'v1',
- served: false,
- storage: false,
- additionalPrinterColumns: [
- {
- name: 'Test Col',
- type: 'string',
- jsonPath: '.metadata.name',
- description: 'My description',
- },
- ],
- },
- ],
- scope: 'Namespaced',
- },
+ ],
+ scope: 'Namespaced',
},
- loadingcrd: null,
};
-const mockCRMap: { [name: string]: KubeObject | null } = {
- mycustomresource_mynamespace: {
+export const mockCRList = [
+ {
kind: 'MyCustomResource',
apiVersion: 'my.phonyresources.io/v1',
metadata: {
@@ -58,7 +50,7 @@ const mockCRMap: { [name: string]: KubeObject | null } = {
selfLink: '1',
},
},
- myothercr_mynamespace: {
+ {
kind: 'MyCustomResource',
apiVersion: 'my.phonyresources.io/v1',
metadata: {
@@ -70,70 +62,4 @@ const mockCRMap: { [name: string]: KubeObject | null } = {
selfLink: '1',
},
},
-};
-
-const CRDMockMethods = {
- usePhonyApiGet: (...args: any) => {
- const [setCRD, name] = args;
- const setError = args[3];
-
- React.useEffect(() => {
- const jsonData = mockCRDMap[name];
- if (jsonData === undefined) {
- const err: ApiError = new Error('No mock CRD for you') as ApiError;
- err['status'] = 404;
- setError && setError(err);
- } else {
- setCRD(!!jsonData ? new CustomResourceDefinition(jsonData) : null);
- }
- }, []);
- },
- usePhonyList: () => {
- const crdInstances: CustomResourceDefinition[] = [];
- Object.values(mockCRDMap).forEach(data => {
- if (!!data) {
- crdInstances.push(new CustomResourceDefinition(data));
- }
- });
-
- return [crdInstances, null, () => {}, () => {}] as any;
- },
-};
-
-class CRMockClass extends makeKubeObject('customresource') {
- static apiEndpoint = apiFactoryWithNamespace(['', '', '']);
-
- static useApiGet(
- setItem: (item: CRMockClass | null) => void,
- name: string,
- namespace?: string,
- setError?: (err: ApiError) => void
- ) {
- React.useEffect(() => {
- const jsonData = mockCRMap[name + '_' + namespace];
- if (jsonData === undefined) {
- const err: ApiError = new Error('No mock custom resource for you') as ApiError;
- err['status'] = 404;
- setError && setError(err);
- } else {
- setItem(!!jsonData ? new CRMockClass(jsonData) : null);
- }
- }, []);
- }
-
- static useApiList(onList: (...arg: any[]) => any) {
- React.useEffect(() => {
- onList(Object.values(mockCRMap).map(cr => new CRMockClass(cr)));
- }, []);
- }
-
- async getAuthorization() {
- return {
- status: {
- allowed: true,
- },
- };
- }
-}
-
-export { mockCRDMap, mockCRMap, CRMockClass, CRDMockMethods };
+];
diff --git a/frontend/src/components/cronjob/CronJobDetails.stories.tsx b/frontend/src/components/cronjob/CronJobDetails.stories.tsx
index 0ea6ec39ebf..e3c96f5374e 100644
--- a/frontend/src/components/cronjob/CronJobDetails.stories.tsx
+++ b/frontend/src/components/cronjob/CronJobDetails.stories.tsx
@@ -12,6 +12,7 @@ export default {
msw: {
handlers: {
storyBase: [
+ http.get('http://localhost:4466/apis/batch/v1/cronjobs', () => HttpResponse.json({})),
http.get('http://localhost:4466/api/v1/namespaces/default/events', () =>
HttpResponse.json({
kind: 'EventList',
diff --git a/frontend/src/components/cronjob/Details.tsx b/frontend/src/components/cronjob/Details.tsx
index 3d42c06c8b6..bf73ccd1516 100644
--- a/frontend/src/components/cronjob/Details.tsx
+++ b/frontend/src/components/cronjob/Details.tsx
@@ -270,9 +270,9 @@ export default function CronJobDetails() {
resourceType={CronJob}
name={name}
namespace={namespace}
- onResourceUpdate={(cronJob: CronJob) => setCronJob(cronJob)}
withEvents
actions={actions}
+ onResourceUpdate={cronJob => setCronJob(cronJob)}
extraInfo={item =>
item && [
{
diff --git a/frontend/src/components/ingress/Details.stories.tsx b/frontend/src/components/ingress/Details.stories.tsx
index a43ec9564b5..0e1f5e7202a 100644
--- a/frontend/src/components/ingress/Details.stories.tsx
+++ b/frontend/src/components/ingress/Details.stories.tsx
@@ -20,7 +20,13 @@ export default {
parameters: {
msw: {
handlers: {
- storyBase: [
+ baseStory: [
+ http.get('http://localhost:4466/apis/networking.k8s.io/v1/ingresses', () =>
+ HttpResponse.json({})
+ ),
+ http.get('http://localhost:4466/apis/extensions/v1beta1/ingresses', () =>
+ HttpResponse.error()
+ ),
http.get('http://localhost:4466/api/v1/namespaces/default/events', () =>
HttpResponse.json({
kind: 'EventList',
@@ -28,6 +34,10 @@ export default {
metadata: {},
})
),
+ http.post(
+ 'http://localhost:4466/apis/authorization.k8s.io/v1/selfsubjectaccessreviews',
+ () => HttpResponse.json({ status: { allowed: true, reason: '', code: 200 } })
+ ),
],
},
},
diff --git a/frontend/src/components/ingress/__snapshots__/ClassList.Items.stories.storyshot b/frontend/src/components/ingress/__snapshots__/ClassList.Items.stories.storyshot
index ddc66690ad1..b92b08f74be 100644
--- a/frontend/src/components/ingress/__snapshots__/ClassList.Items.stories.storyshot
+++ b/frontend/src/components/ingress/__snapshots__/ClassList.Items.stories.storyshot
@@ -449,6 +449,44 @@
+ |
+
+
+ resource-example-ingress
+
+ |
+
+ test
+ |
+
+
+ 3mo
+
+ |
+
+
- 1-1 of 1
+ 1-2 of 2
{
dispatchHeadlampEvent({
- resources: pods,
+ resources: pods ?? [],
resourceKind: 'Pod',
error: error || undefined,
});
diff --git a/frontend/src/components/pod/__snapshots__/PodList.Items.stories.storyshot b/frontend/src/components/pod/__snapshots__/PodList.Items.stories.storyshot
index 57822144210..4f301a2ebce 100644
--- a/frontend/src/components/pod/__snapshots__/PodList.Items.stories.storyshot
+++ b/frontend/src/components/pod/__snapshots__/PodList.Items.stories.storyshot
@@ -957,6 +957,95 @@
+ |
+
+ successful
+
+ |
+
+
+ default
+
+ |
+
+ 0
+ |
+
+ 0/1
+ |
+
+
+
+ Completed
+
+
+ |
+ |
+
+
+
+ my-node
+
+
+ |
+
+
+ 3mo
+
+ |
+
+
|
|
|
|
|
|
|
|
|
|
|
- 1-7 of 7
+ 1-8 of 8
|
+
|
- 1-1 of 1
+ 1-2 of 2
{endpointsError.toString()}
) : (
- no-storage-class-name-pvc
+ my-pvc
|
|
+ >
+
+
+ default
+
+
+
- no-volume-name-pvc
+ no-storage-class-name-pvc
|
- Block
+ Filesystem
|
|
+ >
+
+
+ pvc-abc-1234
+
+
+
- my-pvc
+ no-volume-name-pvc
|
-
-
- default
-
-
- |
+ />
- Filesystem
+ Block
|
-
-
- pvc-abc-1234
-
-
- |
+ />
({});
- const location = useLocation();
- const { t } = useTranslation('glossary');
- const cluster = useCluster();
+ const [pods] = Pod.useList();
+ const [deployments] = Deployment.useList();
+ const [statefulSets] = StatefulSet.useList();
+ const [daemonSets] = DaemonSet.useList();
+ const [replicaSets] = ReplicaSet.useList();
+ const [jobs] = Job.useList();
+ const [cronJobs] = CronJob.useList();
- React.useEffect(() => {
- setWorkloadsData({});
- }, [cluster]);
+ const workloadsData: WorkloadDict = useMemo(
+ () => ({
+ Pod: pods ?? [],
+ Deployment: deployments ?? [],
+ StatefulSet: statefulSets ?? [],
+ DaemonSet: daemonSets ?? [],
+ ReplicaSet: replicaSets ?? [],
+ Job: jobs ?? [],
+ CronJob: cronJobs ?? [],
+ }),
+ [pods, deployments, statefulSets, daemonSets, replicaSets, jobs, cronJobs]
+ );
- function setWorkloads(newWorkloads: WorkloadDict) {
- setWorkloadsData(workloads => ({
- ...workloads,
- ...newWorkloads,
- }));
- }
+ const location = useLocation();
+ const { t } = useTranslation('glossary');
function getPods(item: Workload) {
return `${getReadyReplicas(item)}/${getTotalReplicas(item)}`;
@@ -57,11 +63,6 @@ export default function Overview() {
const jointItems = React.useMemo(() => {
let joint: Workload[] = [];
- // Return null if no items are yet loaded, so we show the spinner in the table.
- if (Object.keys(workloadsData).length === 0) {
- return null;
- }
-
// Get all items except the pods since those shouldn't be shown in the table (only the chart).
for (const [key, items] of Object.entries(workloadsData)) {
if (key === 'Pod') {
@@ -69,6 +70,14 @@ export default function Overview() {
}
joint = joint.concat(items);
}
+
+ joint = joint.filter(Boolean);
+
+ // Return null if no items are yet loaded, so we show the spinner in the table.
+ if (joint.length === 0) {
+ return null;
+ }
+
return joint;
}, [workloadsData]);
@@ -92,18 +101,6 @@ export default function Overview() {
[CronJob.className]: t('glossary|Cron Jobs'),
};
- workloads.forEach((workloadClass: KubeObject) => {
- workloadClass.useApiList(
- (items: InstanceType[]) => {
- setWorkloads({ [workloadClass.className]: items });
- },
- (err: ApiError) => {
- console.error(`Workloads list: Failed to get list for ${workloadClass.className}: ${err}`);
- setWorkloads({ [workloadClass.className]: [] });
- }
- );
- });
-
function ChartLink({ workload }: { workload: KubeObject }) {
return {workloadLabel[workload.className]};
}
diff --git a/frontend/src/helpers/testHelpers.ts b/frontend/src/helpers/testHelpers.ts
new file mode 100644
index 00000000000..e8c4f3ad86e
--- /dev/null
+++ b/frontend/src/helpers/testHelpers.ts
@@ -0,0 +1,34 @@
+import { useKubeObjectList } from '../lib/k8s/api/v2/hooks';
+
+export const useMockListQuery = {
+ noData: () =>
+ ({
+ data: null,
+ items: null,
+ error: null,
+ *[Symbol.iterator]() {
+ yield null;
+ yield null;
+ },
+ } as any as typeof useKubeObjectList),
+ error: () =>
+ ({
+ data: null,
+ items: null,
+ error: 'Phony error is phony!',
+ *[Symbol.iterator]() {
+ yield null;
+ yield 'Phony error is phony!';
+ },
+ } as any as typeof useKubeObjectList),
+ data: (items: any[]) =>
+ (() => ({
+ data: { kind: 'List', items },
+ items,
+ error: null,
+ *[Symbol.iterator]() {
+ yield items;
+ yield null;
+ },
+ })) as any as typeof useKubeObjectList,
+};
diff --git a/frontend/src/lib/k8s/apiProxy/apiProxy.test.ts b/frontend/src/lib/k8s/api/v1/apiProxy.test.ts
similarity index 99%
rename from frontend/src/lib/k8s/apiProxy/apiProxy.test.ts
rename to frontend/src/lib/k8s/api/v1/apiProxy.test.ts
index ebc5ae6e2ff..6fcca64fdf1 100644
--- a/frontend/src/lib/k8s/apiProxy/apiProxy.test.ts
+++ b/frontend/src/lib/k8s/api/v1/apiProxy.test.ts
@@ -6,10 +6,10 @@
import nock from 'nock';
import { Mock, MockedFunction } from 'vitest';
import WS from 'vitest-websocket-mock';
-import exportFunctions from '../../../helpers';
-import * as auth from '../../auth';
-import * as cluster from '../../cluster';
-import * as apiProxy from '../apiProxy';
+import exportFunctions from '../../../../helpers';
+import * as auth from '../../../auth';
+import * as cluster from '../../../cluster';
+import * as apiProxy from '../../apiProxy';
const baseApiUrl = exportFunctions.getAppUrl();
const wsUrl = baseApiUrl.replace('http', 'ws');
diff --git a/frontend/src/lib/k8s/apiProxy/apply.ts b/frontend/src/lib/k8s/api/v1/apply.ts
similarity index 95%
rename from frontend/src/lib/k8s/apiProxy/apply.ts
rename to frontend/src/lib/k8s/api/v1/apply.ts
index f123d8de26e..8c57f0f1497 100644
--- a/frontend/src/lib/k8s/apiProxy/apply.ts
+++ b/frontend/src/lib/k8s/api/v1/apply.ts
@@ -1,6 +1,6 @@
import _ from 'lodash';
-import { getCluster } from '../../cluster';
-import { KubeObjectInterface } from '../cluster';
+import { getCluster } from '../../../cluster';
+import { KubeObjectInterface } from '../../cluster';
import { getClusterDefaultNamespace } from './clusterApi';
import { ApiError } from './clusterRequests';
import { resourceDefToApiFactory } from './factories';
diff --git a/frontend/src/lib/k8s/apiProxy/clusterApi.ts b/frontend/src/lib/k8s/api/v1/clusterApi.ts
similarity index 94%
rename from frontend/src/lib/k8s/apiProxy/clusterApi.ts
rename to frontend/src/lib/k8s/api/v1/clusterApi.ts
index 3dcdc2c7795..dc0dc6c8f75 100644
--- a/frontend/src/lib/k8s/apiProxy/clusterApi.ts
+++ b/frontend/src/lib/k8s/api/v1/clusterApi.ts
@@ -1,12 +1,12 @@
-import helpers, { getHeadlampAPIHeaders } from '../../../helpers';
-import { ConfigState } from '../../../redux/configSlice';
-import store from '../../../redux/stores/store';
+import helpers, { getHeadlampAPIHeaders } from '../../../../helpers';
+import { ConfigState } from '../../../../redux/configSlice';
+import store from '../../../../redux/stores/store';
import {
deleteClusterKubeconfig,
findKubeconfigByClusterName,
storeStatelessClusterKubeconfig,
-} from '../../../stateless';
-import { getCluster } from '../../util';
+} from '../../../../stateless';
+import { getCluster } from '../../../util';
import { ClusterRequest, clusterRequest, post, request } from './clusterRequests';
import { JSON_HEADERS } from './constants';
diff --git a/frontend/src/lib/k8s/apiProxy/clusterRequests.ts b/frontend/src/lib/k8s/api/v1/clusterRequests.ts
similarity index 96%
rename from frontend/src/lib/k8s/apiProxy/clusterRequests.ts
rename to frontend/src/lib/k8s/api/v1/clusterRequests.ts
index 32e45168884..a284221041e 100644
--- a/frontend/src/lib/k8s/apiProxy/clusterRequests.ts
+++ b/frontend/src/lib/k8s/api/v1/clusterRequests.ts
@@ -1,11 +1,11 @@
// @todo: Params is a confusing name for options, because params are also query params.
-import { isDebugVerbose } from '../../../helpers';
-import store from '../../../redux/stores/store';
-import { findKubeconfigByClusterName, getUserIdFromLocalStorage } from '../../../stateless';
-import { getToken, logout, setToken } from '../../auth';
-import { getCluster } from '../../cluster';
-import { KubeObjectInterface } from '../cluster';
+import { isDebugVerbose } from '../../../../helpers';
+import store from '../../../../redux/stores/store';
+import { findKubeconfigByClusterName, getUserIdFromLocalStorage } from '../../../../stateless';
+import { getToken, logout, setToken } from '../../../auth';
+import { getCluster } from '../../../cluster';
+import { KubeObjectInterface } from '../../cluster';
import { BASE_HTTP_URL, CLUSTERS_PREFIX, DEFAULT_TIMEOUT, JSON_HEADERS } from './constants';
import { asQuery, combinePath } from './formatUrl';
import { QueryParameters } from './queryParameters';
diff --git a/frontend/src/lib/k8s/apiProxy/constants.ts b/frontend/src/lib/k8s/api/v1/constants.ts
similarity index 87%
rename from frontend/src/lib/k8s/apiProxy/constants.ts
rename to frontend/src/lib/k8s/api/v1/constants.ts
index 7d9ab6047f1..001bdcece52 100644
--- a/frontend/src/lib/k8s/apiProxy/constants.ts
+++ b/frontend/src/lib/k8s/api/v1/constants.ts
@@ -1,4 +1,4 @@
-import helpers from '../../../helpers';
+import helpers from '../../../../helpers';
export const BASE_HTTP_URL = helpers.getAppUrl();
export const CLUSTERS_PREFIX = 'clusters';
diff --git a/frontend/src/lib/k8s/apiProxy/drainNode.ts b/frontend/src/lib/k8s/api/v1/drainNode.ts
similarity index 96%
rename from frontend/src/lib/k8s/apiProxy/drainNode.ts
rename to frontend/src/lib/k8s/api/v1/drainNode.ts
index be782498a5f..fa920cf1598 100644
--- a/frontend/src/lib/k8s/apiProxy/drainNode.ts
+++ b/frontend/src/lib/k8s/api/v1/drainNode.ts
@@ -1,5 +1,5 @@
-import helpers from '../../../helpers';
-import { getToken } from '../../auth';
+import helpers from '../../../../helpers';
+import { getToken } from '../../../auth';
import { JSON_HEADERS } from './constants';
/**
diff --git a/frontend/src/lib/k8s/apiProxy/factories.ts b/frontend/src/lib/k8s/api/v1/factories.ts
similarity index 99%
rename from frontend/src/lib/k8s/apiProxy/factories.ts
rename to frontend/src/lib/k8s/api/v1/factories.ts
index f6338ab420a..1af272b02d4 100644
--- a/frontend/src/lib/k8s/apiProxy/factories.ts
+++ b/frontend/src/lib/k8s/api/v1/factories.ts
@@ -2,9 +2,9 @@
// is 404 and not trying it again... and again.
import { OpPatch } from 'json-patch';
-import { isDebugVerbose } from '../../../helpers';
-import { getCluster } from '../../cluster';
-import { KubeObjectInterface } from '../cluster';
+import { isDebugVerbose } from '../../../../helpers';
+import { getCluster } from '../../../cluster';
+import { KubeObjectInterface } from '../../cluster';
import { ApiError, clusterRequest, patch, post, put, remove } from './clusterRequests';
import { asQuery, getApiRoot } from './formatUrl';
import { QueryParameters } from './queryParameters';
diff --git a/frontend/src/lib/k8s/apiProxy/formatUrl.ts b/frontend/src/lib/k8s/api/v1/formatUrl.ts
similarity index 94%
rename from frontend/src/lib/k8s/apiProxy/formatUrl.ts
rename to frontend/src/lib/k8s/api/v1/formatUrl.ts
index d88f2c03455..1e6d0a73b54 100644
--- a/frontend/src/lib/k8s/apiProxy/formatUrl.ts
+++ b/frontend/src/lib/k8s/api/v1/formatUrl.ts
@@ -2,7 +2,7 @@ import { omit } from 'lodash';
import { QueryParameters } from './queryParameters';
export function buildUrl(urlOrParts: string | string[], queryParams?: QueryParameters): string {
- const url = Array.isArray(urlOrParts) ? urlOrParts.join('/') : urlOrParts;
+ const url = Array.isArray(urlOrParts) ? urlOrParts.filter(Boolean).join('/') : urlOrParts;
return url + asQuery(queryParams);
}
diff --git a/frontend/src/lib/k8s/apiProxy/metricsApi.ts b/frontend/src/lib/k8s/api/v1/metricsApi.ts
similarity index 89%
rename from frontend/src/lib/k8s/apiProxy/metricsApi.ts
rename to frontend/src/lib/k8s/api/v1/metricsApi.ts
index b20a900f61f..146039539f1 100644
--- a/frontend/src/lib/k8s/apiProxy/metricsApi.ts
+++ b/frontend/src/lib/k8s/api/v1/metricsApi.ts
@@ -1,6 +1,6 @@
-import { isDebugVerbose } from '../../../helpers';
-import { getCluster } from '../../cluster';
-import { KubeMetrics } from '../cluster';
+import { isDebugVerbose } from '../../../../helpers';
+import { getCluster } from '../../../cluster';
+import { KubeMetrics } from '../../cluster';
import { ApiError, clusterRequest } from './clusterRequests';
/**
diff --git a/frontend/src/lib/k8s/apiProxy/pluginsApi.ts b/frontend/src/lib/k8s/api/v1/pluginsApi.ts
similarity index 95%
rename from frontend/src/lib/k8s/apiProxy/pluginsApi.ts
rename to frontend/src/lib/k8s/api/v1/pluginsApi.ts
index 08b6bc23a6f..8e99554a85d 100644
--- a/frontend/src/lib/k8s/apiProxy/pluginsApi.ts
+++ b/frontend/src/lib/k8s/api/v1/pluginsApi.ts
@@ -1,4 +1,4 @@
-import { getHeadlampAPIHeaders } from '../../../helpers';
+import { getHeadlampAPIHeaders } from '../../../../helpers';
import { request } from './clusterRequests';
//@todo: what is DELETE /plugins/name response type? It's not used by headlamp in PLuginSettingsDetail.
diff --git a/frontend/src/lib/k8s/apiProxy/portForward.ts b/frontend/src/lib/k8s/api/v1/portForward.ts
similarity index 97%
rename from frontend/src/lib/k8s/apiProxy/portForward.ts
rename to frontend/src/lib/k8s/api/v1/portForward.ts
index 2082cb1a2e7..f8a295b4b4c 100644
--- a/frontend/src/lib/k8s/apiProxy/portForward.ts
+++ b/frontend/src/lib/k8s/api/v1/portForward.ts
@@ -1,5 +1,5 @@
-import helpers from '../../../helpers';
-import { getToken } from '../../auth';
+import helpers from '../../../../helpers';
+import { getToken } from '../../../auth';
import { JSON_HEADERS } from './constants';
// @todo: the return type is missing for the following functions.
diff --git a/frontend/src/lib/k8s/apiProxy/queryParameters.ts b/frontend/src/lib/k8s/api/v1/queryParameters.ts
similarity index 100%
rename from frontend/src/lib/k8s/apiProxy/queryParameters.ts
rename to frontend/src/lib/k8s/api/v1/queryParameters.ts
diff --git a/frontend/src/lib/k8s/apiProxy/scaleApi.ts b/frontend/src/lib/k8s/api/v1/scaleApi.ts
similarity index 94%
rename from frontend/src/lib/k8s/apiProxy/scaleApi.ts
rename to frontend/src/lib/k8s/api/v1/scaleApi.ts
index ac6f87d3b30..86c23e813a5 100644
--- a/frontend/src/lib/k8s/apiProxy/scaleApi.ts
+++ b/frontend/src/lib/k8s/api/v1/scaleApi.ts
@@ -1,5 +1,5 @@
-import { getCluster } from '../../cluster';
-import { KubeMetadata } from '../cluster';
+import { getCluster } from '../../../cluster';
+import { KubeMetadata } from '../../cluster';
import { clusterRequest, patch, put } from './clusterRequests';
export interface ScaleApi {
diff --git a/frontend/src/lib/k8s/apiProxy/streamingApi.ts b/frontend/src/lib/k8s/api/v1/streamingApi.ts
similarity index 98%
rename from frontend/src/lib/k8s/apiProxy/streamingApi.ts
rename to frontend/src/lib/k8s/api/v1/streamingApi.ts
index 25b72a5f19c..de38e469796 100644
--- a/frontend/src/lib/k8s/apiProxy/streamingApi.ts
+++ b/frontend/src/lib/k8s/api/v1/streamingApi.ts
@@ -1,8 +1,8 @@
-import { isDebugVerbose } from '../../../helpers';
-import { findKubeconfigByClusterName, getUserIdFromLocalStorage } from '../../../stateless';
-import { getToken } from '../../auth';
-import { getCluster } from '../../cluster';
-import { KubeObjectInterface } from '../cluster';
+import { isDebugVerbose } from '../../../../helpers';
+import { findKubeconfigByClusterName, getUserIdFromLocalStorage } from '../../../../stateless';
+import { getToken } from '../../../auth';
+import { getCluster } from '../../../cluster';
+import { KubeObjectInterface } from '../../cluster';
import { ApiError, clusterRequest } from './clusterRequests';
import { BASE_HTTP_URL, CLUSTERS_PREFIX } from './constants';
import { asQuery, combinePath } from './formatUrl';
diff --git a/frontend/src/lib/k8s/apiProxy/tokenApi.ts b/frontend/src/lib/k8s/api/v1/tokenApi.ts
similarity index 93%
rename from frontend/src/lib/k8s/apiProxy/tokenApi.ts
rename to frontend/src/lib/k8s/api/v1/tokenApi.ts
index 9fba69d93de..8f3802c6263 100644
--- a/frontend/src/lib/k8s/apiProxy/tokenApi.ts
+++ b/frontend/src/lib/k8s/api/v1/tokenApi.ts
@@ -1,8 +1,8 @@
import { decodeToken } from 'react-jwt';
-import { isDebugVerbose } from '../../../helpers';
-import { getToken, setToken } from '../../auth';
-import { getCluster } from '../../cluster';
-import { KubeToken } from '../token';
+import { isDebugVerbose } from '../../../../helpers';
+import { getToken, setToken } from '../../../auth';
+import { getCluster } from '../../../cluster';
+import { KubeToken } from '../../token';
import {
BASE_HTTP_URL,
CLUSTERS_PREFIX,
diff --git a/frontend/src/lib/k8s/api/v2/KubeList.test.ts b/frontend/src/lib/k8s/api/v2/KubeList.test.ts
new file mode 100644
index 00000000000..c65e4eab621
--- /dev/null
+++ b/frontend/src/lib/k8s/api/v2/KubeList.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it, vi } from 'vitest';
+import { KubeObjectClass, KubeObjectInterface } from '../../cluster';
+import { KubeList, KubeListUpdateEvent } from './KubeList';
+
+class MockKubeObject implements KubeObjectInterface {
+ apiVersion = 'v1';
+ kind = 'MockKubeObject';
+ metadata: any = {
+ uid: 'mock-uid',
+ resourceVersion: '1',
+ };
+
+ constructor(data: Partial) {
+ Object.assign(this, data);
+ }
+}
+
+describe('KubeList.applyUpdate', () => {
+ const itemClass = MockKubeObject as unknown as KubeObjectClass;
+ const initialList = {
+ kind: 'MockKubeList',
+ apiVersion: 'v1',
+ items: [
+ { apiVersion: 'v1', kind: 'MockKubeObject', metadata: { uid: '1', resourceVersion: '1' } },
+ ],
+ metadata: {
+ resourceVersion: '1',
+ },
+ };
+
+ it('should add a new item on ADDED event', () => {
+ const updateEvent: KubeListUpdateEvent = {
+ type: 'ADDED',
+ object: {
+ apiVersion: 'v1',
+ kind: 'MockKubeObject',
+ metadata: { uid: '2', resourceVersion: '2' },
+ },
+ };
+
+ const updatedList = KubeList.applyUpdate(initialList, updateEvent, itemClass);
+
+ expect(updatedList.items).toHaveLength(2);
+ expect(updatedList.items[1].metadata.uid).toBe('2');
+ expect(updatedList.items[1] instanceof MockKubeObject).toBe(true);
+ });
+
+ it('should modify an existing item on MODIFIED event', () => {
+ const updateEvent: KubeListUpdateEvent = {
+ type: 'MODIFIED',
+ object: {
+ apiVersion: 'v1',
+ kind: 'MockKubeObject',
+ metadata: { uid: '1', resourceVersion: '2' },
+ },
+ };
+
+ const updatedList = KubeList.applyUpdate(initialList, updateEvent, itemClass);
+
+ expect(updatedList.items).toHaveLength(1);
+ expect(updatedList.items[0].metadata.resourceVersion).toBe('2');
+ expect(updatedList.items[0] instanceof MockKubeObject).toBe(true);
+ });
+
+ it('should add a new item on MODIFIED event', () => {
+ const updateEvent: KubeListUpdateEvent = {
+ type: 'MODIFIED',
+ object: {
+ apiVersion: 'v1',
+ kind: 'MockKubeObject',
+ metadata: { uid: '3', resourceVersion: '3' },
+ },
+ };
+
+ const updatedList = KubeList.applyUpdate(initialList, updateEvent, itemClass);
+
+ expect(updatedList.items).toHaveLength(2);
+ expect(updatedList.items[1].metadata.uid).toBe('3');
+ expect(updatedList.items[1] instanceof MockKubeObject).toBe(true);
+ });
+
+ it('should delete an existing item on DELETED event', () => {
+ const updateEvent: KubeListUpdateEvent = {
+ type: 'DELETED',
+ object: {
+ apiVersion: 'v1',
+ kind: 'MockKubeObject',
+ metadata: { uid: '1', resourceVersion: '2' },
+ },
+ };
+
+ const updatedList = KubeList.applyUpdate(initialList, updateEvent, itemClass);
+
+ expect(updatedList.items).toHaveLength(0);
+ });
+
+ it('should log an error on ERROR event', () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const updateEvent: KubeListUpdateEvent = {
+ type: 'ERROR',
+ object: {
+ apiVersion: 'v1',
+ kind: 'MockKubeObject',
+ metadata: { uid: '1', resourceVersion: '2' },
+ },
+ };
+
+ KubeList.applyUpdate(initialList, updateEvent, itemClass);
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Error in update', updateEvent);
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should log an error on unknown event type', () => {
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const updateEvent: KubeListUpdateEvent = {
+ type: 'UNKNOWN' as any,
+ object: {
+ apiVersion: 'v1',
+ kind: 'MockKubeObject',
+ metadata: { uid: '1', resourceVersion: '2' },
+ },
+ };
+
+ KubeList.applyUpdate(initialList, updateEvent, itemClass);
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Unknown update type', updateEvent);
+ consoleErrorSpy.mockRestore();
+ });
+});
diff --git a/frontend/src/lib/k8s/api/v2/KubeList.ts b/frontend/src/lib/k8s/api/v2/KubeList.ts
new file mode 100644
index 00000000000..e15f31dab42
--- /dev/null
+++ b/frontend/src/lib/k8s/api/v2/KubeList.ts
@@ -0,0 +1,63 @@
+import { KubeObjectClass, KubeObjectInterface } from '../../cluster';
+
+export interface KubeList {
+ kind: string;
+ apiVersion: string;
+ items: T[];
+ metadata: {
+ resourceVersion: string;
+ };
+}
+
+export interface KubeListUpdateEvent {
+ type: 'ADDED' | 'MODIFIED' | 'DELETED' | 'ERROR';
+ object: T;
+}
+
+export const KubeList = {
+ /**
+ * Apply an update event to the existing list
+ *
+ * @param list - List of kubernetes resources
+ * @param update - Update event to apply to the list
+ * @param itemClass - Class of an item in the list. Used to instantiate each item
+ * @returns New list with the updated values
+ */
+ applyUpdate(
+ list: KubeList,
+ update: KubeListUpdateEvent,
+ itemClass: KubeObjectClass
+ ): KubeList {
+ const newItems = [...list.items];
+ const index = newItems.findIndex(item => item.metadata.uid === update.object.metadata.uid);
+
+ switch (update.type) {
+ case 'ADDED':
+ case 'MODIFIED':
+ if (index !== -1) {
+ newItems[index] = new itemClass(update.object) as T;
+ } else {
+ newItems.push(new itemClass(update.object) as T);
+ }
+ break;
+ case 'DELETED':
+ if (index !== -1) {
+ newItems.splice(index, 1);
+ }
+ break;
+ case 'ERROR':
+ console.error('Error in update', update);
+ break;
+ default:
+ console.error('Unknown update type', update);
+ }
+
+ return {
+ ...list,
+ metadata: {
+ resourceVersion: update.object.metadata.resourceVersion!,
+ },
+ items: newItems,
+ };
+ },
+};
diff --git a/frontend/src/lib/k8s/api/v2/KubeObjectEndpoint.test.ts b/frontend/src/lib/k8s/api/v2/KubeObjectEndpoint.test.ts
new file mode 100644
index 00000000000..c35e8777051
--- /dev/null
+++ b/frontend/src/lib/k8s/api/v2/KubeObjectEndpoint.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest';
+import { KubeObjectEndpoint } from './KubeObjectEndpoint';
+
+describe('KubeObjectEndpoint', () => {
+ describe('toUrl', () => {
+ it('should generate URL for core resources without namespace', () => {
+ const endpoint = { version: 'v1', resource: 'pods' };
+ const url = KubeObjectEndpoint.toUrl(endpoint);
+ expect(url).toBe('api/v1/pods');
+ });
+
+ it('should generate URL for core resources with namespace', () => {
+ const endpoint = { version: 'v1', resource: 'pods' };
+ const url = KubeObjectEndpoint.toUrl(endpoint, 'default');
+ expect(url).toBe('api/v1/namespaces/default/pods');
+ });
+
+ it('should generate URL for custom resources without namespace', () => {
+ const endpoint = { group: 'apps', version: 'v1', resource: 'deployments' };
+ const url = KubeObjectEndpoint.toUrl(endpoint);
+ expect(url).toBe('apis/apps/v1/deployments');
+ });
+
+ it('should generate URL for custom resources with namespace', () => {
+ const endpoint = { group: 'apps', version: 'v1', resource: 'deployments' };
+ const url = KubeObjectEndpoint.toUrl(endpoint, 'default');
+ expect(url).toBe('apis/apps/v1/namespaces/default/deployments');
+ });
+
+ it('should generate URL for custom resources with empty group', () => {
+ const endpoint = { group: '', version: 'v1', resource: 'services' };
+ const url = KubeObjectEndpoint.toUrl(endpoint);
+ expect(url).toBe('api/v1/services');
+ });
+
+ it('should generate URL for custom resources with empty group and namespace', () => {
+ const endpoint = { group: '', version: 'v1', resource: 'services' };
+ const url = KubeObjectEndpoint.toUrl(endpoint, 'default');
+ expect(url).toBe('api/v1/namespaces/default/services');
+ });
+ });
+});
diff --git a/frontend/src/lib/k8s/api/v2/KubeObjectEndpoint.ts b/frontend/src/lib/k8s/api/v2/KubeObjectEndpoint.ts
new file mode 100644
index 00000000000..42e3a3017be
--- /dev/null
+++ b/frontend/src/lib/k8s/api/v2/KubeObjectEndpoint.ts
@@ -0,0 +1,32 @@
+export interface KubeObjectEndpoint {
+ group?: string;
+ version: string;
+ resource: string;
+}
+
+export const KubeObjectEndpoint = {
+ /**
+ * Formats endpoints information into a URL path
+ *
+ * @param endpoint - Kubernetes resource endpoint definition
+ * @param namespace - Namespace, optional
+ * @returns Formatted URL path
+ */
+ toUrl: ({ group, version, resource }: KubeObjectEndpoint, namespace?: string) => {
+ const parts = [];
+ if (group) {
+ parts.push('apis', group);
+ } else {
+ parts.push('api');
+ }
+ parts.push(version);
+
+ if (namespace) {
+ parts.push('namespaces', namespace);
+ }
+
+ parts.push(resource);
+
+ return parts.join('/');
+ },
+};
diff --git a/frontend/src/lib/k8s/api/v2/fetch.test.ts b/frontend/src/lib/k8s/api/v2/fetch.test.ts
new file mode 100644
index 00000000000..b0fa8b3f206
--- /dev/null
+++ b/frontend/src/lib/k8s/api/v2/fetch.test.ts
@@ -0,0 +1,90 @@
+import nock from 'nock';
+import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest';
+import { findKubeconfigByClusterName, getUserIdFromLocalStorage } from '../../../../stateless';
+import { getToken, setToken } from '../../../auth';
+import { getClusterAuthType } from '../v1/clusterRequests';
+import { BASE_HTTP_URL, clusterFetch } from './fetch';
+
+vi.mock('../../../auth', () => ({
+ getToken: vi.fn(),
+ setToken: vi.fn(),
+}));
+
+vi.mock('../../../../stateless', () => ({
+ findKubeconfigByClusterName: vi.fn(),
+ getUserIdFromLocalStorage: vi.fn(),
+}));
+
+vi.mock('../v1/clusterRequests', () => ({
+ getClusterAuthType: vi.fn(),
+}));
+
+vi.mock('../v1/tokenApi', () => ({
+ refreshToken: vi.fn(),
+}));
+
+describe('clusterFetch', () => {
+ const clusterName = 'test-cluster';
+ const testUrl = '/test/url';
+ const mockResponse = { message: 'mock response' };
+ const token = 'test-token';
+ const newToken = 'new-token';
+ const kubeconfig = 'mock-kubeconfig';
+ const userID = 'mock-user-id';
+
+ beforeEach(() => {
+ vi.resetAllMocks();
+ (getToken as Mock).mockReturnValue(token);
+ (findKubeconfigByClusterName as Mock).mockResolvedValue(kubeconfig);
+ (getUserIdFromLocalStorage as Mock).mockReturnValue(userID);
+ (getClusterAuthType as Mock).mockReturnValue('serviceAccount');
+ });
+
+ afterEach(() => {
+ nock.cleanAll();
+ });
+
+ it('Successfully makes a request', async () => {
+ nock(BASE_HTTP_URL).get(`/clusters/${clusterName}${testUrl}`).reply(200, mockResponse);
+
+ const response = await clusterFetch(testUrl, { cluster: clusterName });
+ const responseBody = await response.json();
+
+ expect(responseBody).toEqual(mockResponse);
+ });
+
+ it('Sets Authorization header with token', async () => {
+ nock(BASE_HTTP_URL)
+ .get(`/clusters/${clusterName}${testUrl}`)
+ .matchHeader('Authorization', `Bearer ${token}`)
+ .reply(200, mockResponse);
+
+ await clusterFetch(testUrl, { cluster: clusterName });
+ });
+
+ it('Sets KUBECONFIG and X-HEADLAMP-USER-ID headers if kubeconfig exists', async () => {
+ nock(BASE_HTTP_URL)
+ .get(`/clusters/${clusterName}${testUrl}`)
+ .matchHeader('KUBECONFIG', kubeconfig)
+ .matchHeader('X-HEADLAMP-USER-ID', userID)
+ .reply(200, mockResponse);
+
+ await clusterFetch(testUrl, { cluster: clusterName });
+ });
+
+ it('Sets new token if X-Authorization header is present in response', async () => {
+ nock(BASE_HTTP_URL)
+ .get(`/clusters/${clusterName}${testUrl}`)
+ .reply(200, mockResponse, { 'X-Authorization': newToken });
+
+ await clusterFetch(testUrl, { cluster: clusterName });
+
+ expect(setToken).toHaveBeenCalledWith(clusterName, newToken);
+ });
+
+ it('Throws an error if response is not ok', async () => {
+ nock(BASE_HTTP_URL).get(`/clusters/${clusterName}${testUrl}`).reply(500);
+
+ await expect(clusterFetch(testUrl, { cluster: clusterName })).rejects.toThrow('Unreachable');
+ });
+});
diff --git a/frontend/src/lib/k8s/api/v2/fetch.ts b/frontend/src/lib/k8s/api/v2/fetch.ts
new file mode 100644
index 00000000000..27608e36e72
--- /dev/null
+++ b/frontend/src/lib/k8s/api/v2/fetch.ts
@@ -0,0 +1,84 @@
+import helpers from '../../../../helpers';
+import { findKubeconfigByClusterName, getUserIdFromLocalStorage } from '../../../../stateless';
+import { getToken, setToken } from '../../../auth';
+import { getClusterAuthType } from '../v1/clusterRequests';
+import { refreshToken } from '../v1/tokenApi';
+import { makeUrl } from './makeUrl';
+
+export const BASE_HTTP_URL = helpers.getAppUrl();
+
+/**
+ * Simple wrapper around Fetch function
+ * Sends a request to the backend
+ *
+ * @param url - URL path
+ * @param init - options parameter for the Fetch function
+ *
+ * @returns fetch Response
+ */
+async function backendFetch(url: string | URL, init: RequestInit) {
+ const response = await fetch(makeUrl([BASE_HTTP_URL, url]), init);
+
+ // The backend signals through this header that it wants a reload.
+ // See plugins.go
+ const headerVal = response.headers.get('X-Reload');
+ if (headerVal && headerVal.indexOf('reload') !== -1) {
+ window.location.reload();
+ }
+
+ if (!response.ok) {
+ throw new Error('Error: Unreachable');
+ }
+
+ return response;
+}
+
+/**
+ * A wrapper around Fetch function
+ * Allows sending requests to a particular cluster
+ *
+ * @param url - URL path
+ * @param init - same as second parameter of the Fetch function
+ * @param init.cluster - name of the cluster
+ *
+ * @returns fetch Response
+ */
+export async function clusterFetch(url: string | URL, init: RequestInit & { cluster: string }) {
+ const token = getToken(init.cluster);
+
+ init.headers = new Headers(init.headers);
+
+ // Set stateless kubeconfig if exists
+ const kubeconfig = await findKubeconfigByClusterName(init.cluster);
+ if (kubeconfig !== null) {
+ const userID = getUserIdFromLocalStorage();
+ init.headers.set('KUBECONFIG', kubeconfig);
+ init.headers.set('X-HEADLAMP-USER-ID', userID);
+ }
+
+ // Refresh service account token only if the cluster auth type is not OIDC
+ if (getClusterAuthType(init.cluster) !== 'oidc') {
+ await refreshToken(token);
+ }
+
+ if (token) {
+ init.headers.set('Authorization', `Bearer ${token}`);
+ }
+
+ const urlParts = init.cluster ? ['clusters', init.cluster, url] : [url];
+
+ try {
+ const response = await backendFetch(makeUrl(urlParts), init);
+ // In case of OIDC auth if the token is about to expire the backend
+ // sends a refreshed token in the response header.
+ const newToken = response.headers.get('X-Authorization');
+ if (newToken && init.cluster) {
+ setToken(init.cluster, newToken);
+ }
+
+ return response;
+ } catch (e) {
+ console.error(e);
+ throw new Error('Unreachable');
+ }
+}
diff --git a/frontend/src/lib/k8s/api/v2/hooks.ts b/frontend/src/lib/k8s/api/v2/hooks.ts
new file mode 100644
index 00000000000..babcc2b8ceb
--- /dev/null
+++ b/frontend/src/lib/k8s/api/v2/hooks.ts
@@ -0,0 +1,277 @@
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { useMemo } from 'react';
+import { getCluster } from '../../../cluster';
+import { ApiError, QueryParameters } from '../../apiProxy';
+import { KubeObjectClass, KubeObjectInterface } from '../../cluster';
+import { clusterFetch } from './fetch';
+import { KubeList, KubeListUpdateEvent } from './KubeList';
+import { KubeObjectEndpoint } from './KubeObjectEndpoint';
+import { makeUrl } from './makeUrl';
+import { useWebSocket } from './webSocket';
+
+export type QueryStatus = 'pending' | 'success' | 'error';
+
+export interface QueryResponse {
+ /**
+ * The last successfully resolved data for the query.
+ */
+ data: DataType | null;
+ /**
+ * The error object for the query, if an error was thrown.
+ * - Defaults to `null`.
+ */
+ error: ErrorType | null;
+ /**
+ * A derived boolean from the `status` variable, provided for convenience.
+ * - `true` if the query attempt resulted in an error.
+ */
+ isError: boolean;
+ /**
+ * Is `true` whenever the first fetch for a query is in-flight.
+ */
+ isLoading: boolean;
+ /**
+ * Is `true` whenever the query is executing, which includes initial fetch as well as background refetch.
+ */
+ isFetching: boolean;
+ /**
+ * A derived boolean from the `status` variable, provided for convenience.
+ * - `true` if the query has received a response with no errors and is ready to display its data.
+ */
+ isSuccess: boolean;
+ /**
+ * The status of the query.
+ * - Will be:
+ * - `pending` if there's no cached data and no query attempt was finished yet.
+ * - `error` if the query attempt resulted in an error.
+ * - `success` if the query has received a response with no errors and is ready to display its data.
+ */
+ status: QueryStatus;
+}
+
+/**
+ * Query response containing KubeList with added items field for convenience
+ */
+export interface QueryListResponse
+ extends QueryResponse {
+ items: Array | null;
+}
+
+/**
+ * Returns a single KubeObject.
+ */
+export function useKubeObject({
+ kubeObjectClass,
+ namespace,
+ name,
+ cluster: maybeCluster,
+ queryParams,
+}: {
+ /** Class to instantiate the object with */
+ kubeObjectClass: T;
+ /** Object namespace */
+ namespace?: string;
+ /** Object name */
+ name: string;
+ /** Cluster name */
+ cluster?: string;
+ queryParams?: QueryParameters;
+}): [InstanceType | null, ApiError | null] & QueryResponse, ApiError> {
+ type Instance = InstanceType;
+ const endpoint = useEndpoints(kubeObjectClass.apiEndpoint.apiInfo);
+ const cluster = maybeCluster ?? getCluster() ?? '';
+
+ const cleanedUpQueryParams = Object.fromEntries(
+ Object.entries(queryParams ?? {}).filter(([, value]) => value !== undefined && value !== '')
+ );
+
+ const queryKey = useMemo(
+ () => ['object', cluster, endpoint, namespace, name, cleanedUpQueryParams],
+ [endpoint, namespace, name]
+ );
+
+ const client = useQueryClient();
+ const query = useQuery({
+ enabled: !!endpoint,
+ placeholderData: null,
+ staleTime: 5000,
+ queryKey,
+ queryFn: async () => {
+ if (!endpoint) return;
+ const url = makeUrl(
+ [KubeObjectEndpoint.toUrl(endpoint, namespace), name],
+ cleanedUpQueryParams
+ );
+ const obj: KubeObjectInterface = await clusterFetch(url, {
+ cluster,
+ }).then(it => it.json());
+ return new kubeObjectClass(obj);
+ },
+ });
+
+ const data: Instance | null = query.error ? null : query.data ?? null;
+
+ useWebSocket>({
+ url: () =>
+ makeUrl([KubeObjectEndpoint.toUrl(endpoint!)], {
+ ...cleanedUpQueryParams,
+ watch: 1,
+ fieldSelector: `metadata.name=${name}`,
+ }),
+ enabled: !!endpoint && !!data,
+ cluster,
+ onMessage(update) {
+ if (update.type !== 'ADDED' && update.object) {
+ client.setQueryData(queryKey, new kubeObjectClass(update.object));
+ }
+ },
+ });
+
+ // @ts-ignore
+ return {
+ data,
+ error: query.error,
+ isError: query.isError,
+ isLoading: query.isLoading,
+ isFetching: query.isFetching,
+ isSuccess: query.isSuccess,
+ status: query.status,
+ *[Symbol.iterator]() {
+ yield data;
+ yield query.error;
+ },
+ };
+}
+
+/**
+ * Test different endpoints to see which one is working.
+ *
+ * @params endpoints - List of possible endpoints
+ * @returns Endpoint that works
+ *
+ * @throws Error
+ * When no endpoints are working
+ */
+const getWorkingEndpoint = async (endpoints: KubeObjectEndpoint[]) => {
+ const promises = endpoints.map(endpoint => {
+ return clusterFetch(KubeObjectEndpoint.toUrl(endpoint), {
+ method: 'GET',
+ cluster: getCluster() ?? '',
+ }).then(it => {
+ if (!it.ok) {
+ throw new Error('error');
+ }
+ return endpoint;
+ });
+ });
+ return Promise.any(promises);
+};
+
+/**
+ * Checks and returns an endpoint that works from the list
+ *
+ * @params endpoints - List of possible endpoints
+ */
+const useEndpoints = (endpoints: KubeObjectEndpoint[]) => {
+ const { data: endpoint } = useQuery({
+ enabled: endpoints.length > 1,
+ queryKey: ['endpoints', endpoints],
+ queryFn: () =>
+ getWorkingEndpoint(endpoints)
+ .then(endpoints => endpoints)
+ .catch(() => null),
+ });
+
+ if (endpoints.length === 1) return endpoints[0];
+
+ return endpoint;
+};
+
+/**
+ * Returns a list of Kubernetes objects and watches for changes
+ */
+export function useKubeObjectList({
+ kubeObjectClass,
+ namespace,
+ cluster: maybeCluster,
+ queryParams,
+}: {
+ /** Class to instantiate the object with */
+ kubeObjectClass: T;
+ /** Object list namespace */
+ namespace?: string;
+ /** Object list cluster */
+ cluster?: string;
+ queryParams?: QueryParameters;
+}): [Array> | null, ApiError | null] &
+ QueryListResponse>, InstanceType, ApiError> {
+ const endpoint = useEndpoints(kubeObjectClass.apiEndpoint.apiInfo);
+
+ const cleanedUpQueryParams = Object.fromEntries(
+ Object.entries(queryParams ?? {}).filter(([, value]) => value !== undefined && value !== '')
+ );
+
+ const cluster = maybeCluster ?? getCluster() ?? '';
+
+ const queryKey = useMemo(
+ () => ['list', cluster, endpoint, namespace, cleanedUpQueryParams],
+ [endpoint, namespace, cleanedUpQueryParams]
+ );
+
+ const client = useQueryClient();
+ const query = useQuery | null | undefined, ApiError>({
+ enabled: !!endpoint,
+ placeholderData: null,
+ queryKey,
+ queryFn: async () => {
+ if (!endpoint) return;
+ const list: KubeList = await clusterFetch(
+ makeUrl([KubeObjectEndpoint.toUrl(endpoint!, namespace)], cleanedUpQueryParams),
+ {
+ cluster,
+ }
+ ).then(it => it.json());
+ list.items = list.items.map(
+ item => new kubeObjectClass({ ...item, kind: list.kind.replace('List', '') })
+ );
+
+ return list;
+ },
+ });
+
+ const items: Array> | null = query.error ? null : query.data?.items ?? null;
+ const data: KubeList> | null = query.error ? null : query.data ?? null;
+
+ useWebSocket>>({
+ 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]() {
+ yield items;
+ yield query.error;
+ },
+ };
+}
diff --git a/frontend/src/lib/k8s/api/v2/makeUrl.test.ts b/frontend/src/lib/k8s/api/v2/makeUrl.test.ts
new file mode 100644
index 00000000000..2dc6c2bcf51
--- /dev/null
+++ b/frontend/src/lib/k8s/api/v2/makeUrl.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import { makeUrl } from './makeUrl';
+
+describe('makeUrl', () => {
+ it('should create a URL from parts without query parameters', () => {
+ const urlParts = ['http://example.com', 'path', 'to', 'resource'];
+ const result = makeUrl(urlParts);
+ expect(result).toBe('http://example.com/path/to/resource');
+ });
+
+ it('should create a URL from parts with query parameters', () => {
+ const urlParts = ['http://example.com', 'path', 'to', 'resource'];
+ const query = { key1: 'value1', key2: 'value2' };
+ const result = makeUrl(urlParts, query);
+ expect(result).toBe('http://example.com/path/to/resource?key1=value1&key2=value2');
+ });
+
+ it('should handle empty urlParts', () => {
+ const urlParts: any[] = [];
+ const result = makeUrl(urlParts);
+ expect(result).toBe('');
+ });
+
+ it('should handle empty query parameters', () => {
+ const urlParts = ['http://example.com', 'path', 'to', 'resource'];
+ const query = {};
+ const result = makeUrl(urlParts, query);
+ expect(result).toBe('http://example.com/path/to/resource');
+ });
+
+ it('should replace multiple slashes with a single one', () => {
+ const urlParts = ['http://example.com/', '/path/', '/to/', '/resource'];
+ const result = makeUrl(urlParts);
+ expect(result).toBe('http://example.com/path/to/resource');
+ });
+
+ it('should handle special characters in query parameters', () => {
+ const urlParts = ['http://example.com', 'path', 'to', 'resource'];
+ const query = {
+ 'key with spaces': 'value with spaces',
+ 'key&with&special&chars': 'value&with&special&chars',
+ };
+ const result = makeUrl(urlParts, query);
+ expect(result).toBe(
+ 'http://example.com/path/to/resource?key+with+spaces=value+with+spaces&key%26with%26special%26chars=value%26with%26special%26chars'
+ );
+ });
+
+ it('should handle numeric and boolean values in urlParts', () => {
+ const urlParts = ['http://example.com', 123, true, 'resource'];
+ const result = makeUrl(urlParts);
+ expect(result).toBe('http://example.com/123/true/resource');
+ });
+});
diff --git a/frontend/src/lib/k8s/api/v2/makeUrl.ts b/frontend/src/lib/k8s/api/v2/makeUrl.ts
new file mode 100644
index 00000000000..979c473bd24
--- /dev/null
+++ b/frontend/src/lib/k8s/api/v2/makeUrl.ts
@@ -0,0 +1,26 @@
+/**
+ * Formats URL path
+ *
+ * @example
+ * ```ts
+ * makeUrl(["my", "path", 5], { name: "hello" })
+ * // returns "/my/path/5?name=hello"
+ * ```
+ *
+ * @param urlParts - parts of the path, will be separated by /
+ * @param query - query parameters object
+ *
+ * @returns Formatted URL path
+ */
+export function makeUrl(urlParts: any[], query: Record = {}) {
+ const url = urlParts
+ .map(it => (typeof it === 'string' ? it : String(it)))
+ .filter(Boolean)
+ .join('/');
+ const queryString = new URLSearchParams(query).toString();
+ const fullUrl = queryString ? `${url}?${queryString}` : url;
+
+ // replace multiple slashes with a single one
+ // unless it is part of the protocol
+ return fullUrl.replace(/([^:]\/)\/+/g, '$1');
+}
diff --git a/frontend/src/lib/k8s/api/v2/webSocket.ts b/frontend/src/lib/k8s/api/v2/webSocket.ts
new file mode 100644
index 00000000000..636d230cd55
--- /dev/null
+++ b/frontend/src/lib/k8s/api/v2/webSocket.ts
@@ -0,0 +1,169 @@
+import { useEffect, useMemo } from 'react';
+import { findKubeconfigByClusterName, getUserIdFromLocalStorage } from '../../../../stateless';
+import { getToken } from '../../../auth';
+import { getCluster } from '../../../cluster';
+import { BASE_HTTP_URL } from './fetch';
+import { makeUrl } from './makeUrl';
+
+const BASE_WS_URL = BASE_HTTP_URL.replace('http', 'ws');
+
+/**
+ * Create new WebSocket connection to the backend
+ *
+ * @param url - WebSocket URL
+ * @param options - Connection options
+ *
+ * @returns WebSocket connection
+ */
+export async function openWebSocket(
+ url: string,
+ {
+ protocols: moreProtocols = [],
+ type = 'binary',
+ cluster = getCluster() ?? '',
+ onMessage,
+ }: {
+ /**
+ * Any additional protocols to include in WebSocket connection
+ */
+ protocols?: string | string[];
+ /**
+ *
+ */
+ type: 'json' | 'binary';
+ /**
+ * Cluster name
+ */
+ cluster?: string;
+ /**
+ * Message callback
+ */
+ onMessage: (data: T) => void;
+ }
+) {
+ const path = [url];
+ const protocols = ['base64.binary.k8s.io', ...(moreProtocols ?? [])];
+
+ const token = getToken(cluster);
+ if (token) {
+ const encodedToken = btoa(token).replace(/=/g, '');
+ protocols.push(`base64url.bearer.authorization.k8s.io.${encodedToken}`);
+ }
+
+ if (cluster) {
+ path.unshift('clusters', cluster);
+
+ try {
+ const kubeconfig = await findKubeconfigByClusterName(cluster);
+
+ if (kubeconfig !== null) {
+ const userID = getUserIdFromLocalStorage();
+ protocols.push(`base64url.headlamp.authorization.k8s.io.${userID}`);
+ }
+ } catch (error) {
+ console.error('Error while finding kubeconfig:', error);
+ }
+ }
+
+ const socket = new WebSocket(makeUrl([BASE_WS_URL, ...path], {}), protocols);
+ socket.binaryType = 'arraybuffer';
+ socket.addEventListener('message', (body: MessageEvent) => {
+ const data = type === 'json' ? JSON.parse(body.data) : body.data;
+ onMessage(data);
+ });
+ socket.addEventListener('error', error => {
+ console.error('WebSocket error:', error);
+ });
+
+ return socket;
+}
+
+// Global state for useWebSocket hook
+// Keeps track of open WebSocket connections and active listeners
+const sockets = new Map();
+const listeners = new Map void>>();
+
+/**
+ * Creates or joins existing WebSocket connection
+ *
+ * @param url - endpoint URL
+ * @param options - WebSocket options
+ */
+export function useWebSocket({
+ url: createUrl,
+ enabled = true,
+ protocols,
+ type = 'json',
+ cluster,
+ onMessage,
+}: {
+ url: () => string;
+ enabled?: boolean;
+ /**
+ * Any additional protocols to include in WebSocket connection
+ */
+ protocols?: string | string[];
+ /**
+ *
+ */
+ type?: 'json' | 'binary';
+ /**
+ * Cluster name
+ */
+ cluster?: string;
+ /**
+ * Message callback
+ */
+ onMessage: (data: T) => void;
+}) {
+ const url = useMemo(() => (enabled ? createUrl() : ''), [enabled]);
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ // Add new listener for this URL
+ listeners.set(url, [...(listeners.get(url) ?? []), onMessage]);
+
+ let isCurrent = true;
+ async function init() {
+ // Mark socket as pending, so we don't open more than one
+ sockets.set(url, 'pending');
+ const ws = await openWebSocket(url, { protocols, type, cluster, onMessage });
+
+ // Hook was unmounted while it was connecting to WebSocket
+ // so we close the socket and clean up
+ if (!isCurrent) {
+ ws.close();
+ sockets.delete(url);
+ return;
+ }
+
+ sockets.set(url, ws);
+ }
+
+ // Check if we already have a connection (even if still pending)
+ if (!sockets.has(url)) {
+ init();
+ }
+
+ return () => {
+ isCurrent = false;
+
+ // Clean up the listener
+ const newListeners = listeners.get(url)?.filter(it => it !== onMessage) ?? [];
+ listeners.set(url, newListeners);
+
+ // No one is listening to the connection
+ // so we can close it
+ if (newListeners.length === 0) {
+ const maybeExisting = sockets.get(url);
+ if (maybeExisting) {
+ if (maybeExisting !== 'pending') {
+ maybeExisting.close();
+ }
+ sockets.delete(url);
+ }
+ }
+ };
+ }, [enabled, url, protocols, type, cluster]);
+}
diff --git a/frontend/src/lib/k8s/apiProxy/index.ts b/frontend/src/lib/k8s/apiProxy/index.ts
index df5824a5845..2c2fc5a93f1 100644
--- a/frontend/src/lib/k8s/apiProxy/index.ts
+++ b/frontend/src/lib/k8s/apiProxy/index.ts
@@ -21,7 +21,7 @@
// import { debugVerbose } from '../../helpers';
// debugVerbose('k8s/apiProxy');
-export type { QueryParameters } from './queryParameters';
+export type { QueryParameters } from '../api/v1/queryParameters';
// Basic cluster API functions
export {
@@ -35,7 +35,7 @@ export {
type ClusterRequest,
type ClusterRequestParams,
type RequestParams,
-} from './clusterRequests';
+} from '../api/v1/clusterRequests';
// Streaming API functions
export {
@@ -47,7 +47,7 @@ export {
type StreamResultsParams,
type StreamResultsCb,
type StreamErrCb,
-} from './streamingApi';
+} from '../api/v1/streamingApi';
// API factory functions
export {
@@ -56,10 +56,10 @@ export {
type ApiInfo,
type ApiClient,
type ApiWithNamespaceClient,
-} from './factories';
+} from '../api/v1/factories';
// Port forward functions
-export { listPortForward, startPortForward, stopOrDeletePortForward } from './portForward';
+export { listPortForward, startPortForward, stopOrDeletePortForward } from '../api/v1/portForward';
export {
deleteCluster,
@@ -68,10 +68,10 @@ export {
testClusterHealth,
parseKubeConfig,
renameCluster,
-} from './clusterApi';
-export { metrics } from './metricsApi';
-export { deletePlugin } from './pluginsApi';
+} from '../api/v1/clusterApi';
+export { metrics } from '../api/v1/metricsApi';
+export { deletePlugin } from '../api/v1/pluginsApi';
-export { drainNodeStatus, drainNode } from './drainNode';
+export { drainNodeStatus, drainNode } from '../api/v1/drainNode';
-export { apply } from './apply';
+export { apply } from '../api/v1/apply';
diff --git a/frontend/src/lib/k8s/cluster.ts b/frontend/src/lib/k8s/cluster.ts
index 45bfa0f7bbb..e6f480d7ba0 100644
--- a/frontend/src/lib/k8s/cluster.ts
+++ b/frontend/src/lib/k8s/cluster.ts
@@ -4,8 +4,9 @@ import { cloneDeep, unset } from 'lodash';
import React from 'react';
import helpers from '../../helpers';
import { createRouteURL } from '../router';
-import { getCluster, timeAgo, useErrorState } from '../util';
-import { useCluster, useConnectApi } from '.';
+import { getCluster, timeAgo } from '../util';
+import { useConnectApi } from '.';
+import { useKubeObject, useKubeObjectList } from './api/v2/hooks';
import { ApiError, apiFactory, apiFactoryWithNamespace, post, QueryParameters } from './apiProxy';
import CronJob from './cronJob';
import DaemonSet from './daemonSet';
@@ -323,13 +324,15 @@ export interface KubeObjectIface {
namespace?: string,
onError?: (err: ApiError) => void
) => void;
- useList: (
- opts?: ApiListOptions
- ) => [any[], ApiError | null, (items: any[]) => void, (err: ApiError | null) => void];
+ useList(options?: ApiListOptions): ReturnType;
useGet: (
name: string,
- namespace?: string
- ) => [any, ApiError | null, (item: any) => void, (err: ApiError | null) => void];
+ namespace?: string,
+ opts?: {
+ queryParams?: QueryParameters;
+ cluster?: string;
+ }
+ ) => ReturnType;
getErrorMessage: (err?: ApiError | null) => string | null;
new (json: T): any;
className: string;
@@ -583,32 +586,38 @@ export function makeKubeObject(
useConnectApi(...listCalls);
}
- static useList(
- opts?: ApiListOptions
- ): [U[] | null, ApiError | null, (items: U[]) => void, (err: ApiError | null) => void] {
- const [objList, setObjList] = React.useState(null);
- const [error, setError] = useErrorState(setObjList);
- const currentCluster = useCluster();
- const cluster = opts?.cluster || currentCluster;
-
- // Reset the list and error when the cluster changes.
- React.useEffect(() => {
- setObjList(null);
- setError(null);
- }, [cluster]);
-
- function setList(items: U[] | null) {
- setObjList(items);
- if (items !== null) {
- setError(null);
- }
- }
-
- this.useApiList(setList, setError, opts);
+ static useList(
+ this: U,
+ {
+ cluster,
+ namespace,
+ ...queryParams
+ }: { cluster?: string; namespace?: string } & QueryParameters = {}
+ ) {
+ return useKubeObjectList({
+ queryParams: queryParams,
+ kubeObjectClass: this,
+ cluster: cluster,
+ namespace: namespace,
+ });
+ }
- // Return getters and then the setters as the getters are more likely to be used with
- // this function.
- return [objList, error, setObjList, setError];
+ static useGet(
+ this: U,
+ name: string,
+ namespace?: string,
+ opts?: {
+ queryParams?: QueryParameters;
+ cluster?: string;
+ }
+ ) {
+ return useKubeObject({
+ kubeObjectClass: this,
+ name: name,
+ namespace: namespace,
+ cluster: opts?.cluster,
+ queryParams: opts?.queryParams,
+ });
}
static create(this: new (arg: T) => U, item: T): U {
@@ -655,47 +664,6 @@ export function makeKubeObject(
useConnectApi(this.apiGet(getCallback, name, namespace, onError, opts));
}
- static useGet(
- name: string,
- namespace?: string,
- opts?: {
- queryParams?: QueryParameters;
- cluster?: string;
- }
- ): [U | null, ApiError | null, (items: U) => void, (err: ApiError | null) => void] {
- const [obj, setObj] = React.useState(null);
- const [error, setError] = useErrorState(setObj);
-
- function onGet(item: U | null) {
- // Only set the object if we have we have a different one.
- if (!!obj && !!item && obj.metadata.resourceVersion === item.metadata.resourceVersion) {
- return;
- }
-
- setObj(item);
- if (item !== null) {
- setError(null);
- }
- }
-
- function onError(err: ApiError | null) {
- if (
- error === err ||
- (!!error && !!err && error.message === err.message && error.status === err.status)
- ) {
- return;
- }
-
- setError(err);
- }
-
- this.useApiGet(onGet, name, namespace, onError, opts);
-
- // Return getters and then the setters as the getters are more likely to be used with
- // this function.
- return [obj, error, setObj, setError];
- }
-
private _class() {
return this.constructor as typeof KubeObject;
}
diff --git a/frontend/src/lib/k8s/crd.ts b/frontend/src/lib/k8s/crd.ts
index 91f7763e4ee..65ca1ddbee3 100644
--- a/frontend/src/lib/k8s/crd.ts
+++ b/frontend/src/lib/k8s/crd.ts
@@ -145,7 +145,7 @@ export function makeCustomResourceClass(
}
// Used for tests
- if (import.meta.env.UNDER_TEST === 'true') {
+ if (import.meta.env.UNDER_TEST || import.meta.env.STORYBOOK) {
const knownClass = ResourceClasses[apiInfoArgs[0][2]];
if (!!knownClass) {
return knownClass;
diff --git a/frontend/src/lib/k8s/event.ts b/frontend/src/lib/k8s/event.ts
index 5ede3b043ad..3792e69b613 100644
--- a/frontend/src/lib/k8s/event.ts
+++ b/frontend/src/lib/k8s/event.ts
@@ -1,5 +1,5 @@
-import React from 'react';
-import { CancellablePromise, ResourceClasses } from '.';
+import { useMemo } from 'react';
+import { ResourceClasses } from '.';
import { ApiError, apiFactoryWithNamespace, QueryParameters } from './apiProxy';
import { request } from './apiProxy';
import { KubeMetadata, KubeObject, makeKubeObject } from './cluster';
@@ -164,77 +164,55 @@ class Event extends makeKubeObject('Event') {
return objInstance;
}
- static useListForClusters(clusterNames: string[], options?: { queryParams?: QueryParameters }) {
- type EventErrorObj = {
+ /**
+ * Fetch events for given clusters
+ *
+ * Important! Make sure to have the parent component have clusters as a key
+ * so that component remounts when clusters change, instead of rerendering
+ * with different number of clusters
+ */
+ static useListForClusters(
+ clusterNames: string[],
+ options: { queryParams?: QueryParameters } = {}
+ ) {
+ // Calling hooks in a loop is usually forbidden
+ // But if we make sure that clusters don't change between renders it's fine
+ const queries = clusterNames.map(cluster => {
+ return Event.useList({ cluster, ...options.queryParams });
+ });
+
+ type EventsPerCluster = {
[cluster: string]: {
warnings: Event[];
error?: ApiError | null;
};
};
- const [clusters, setClusters] = React.useState>(new Set(clusterNames));
- const [events, setEvents] = React.useState({});
- const queryParameters = Object.assign(
- { limit: this.maxEventsLimit },
- options?.queryParams ?? {}
- );
- // Make sure we only update when there are different cluster names
- React.useEffect(() => {
- let shouldUpdate = false;
- for (const cluster of clusterNames) {
- if (!clusters.has(cluster)) {
- shouldUpdate = true;
- break;
- }
- }
- if (shouldUpdate) {
- setClusters(new Set(clusterNames));
- }
- }, [clusters, clusterNames]);
-
- React.useEffect(() => {
- if (clusters.size === 0) {
- console.debug('No clusters specified when fetching warnings');
- }
- const cancellables: CancellablePromise[] = [];
- for (const cluster of clusters) {
- const cancelFunc = Event.apiList(
- (events: Event[]) => {
- setEvents(prevWarnings => ({
- ...prevWarnings,
- [cluster]: {
- warnings: events,
- error: null,
- },
- }));
- },
- error => {
- setEvents(prevWarnings => ({
- ...prevWarnings,
- [cluster]: {
- warnings: [],
- error,
- },
- }));
- },
- {
- cluster: cluster,
- queryParams: queryParameters,
- }
- )();
- cancellables.push(cancelFunc);
- }
-
- return function cancelAllConnectedListings() {
- for (const cancellable of cancellables) {
- cancellable.then(c => c());
- }
- };
- }, [clusters]);
+ const result = useMemo(() => {
+ const res: EventsPerCluster = {};
+
+ queries.forEach((query, index) => {
+ const cluster = clusterNames[index];
+ res[cluster] = {
+ warnings: query.data?.items ?? [],
+ error: query.error as ApiError,
+ };
+ });
+
+ return res;
+ }, [queries, clusterNames]);
- return events;
+ return result;
}
+ /**
+ * Fetch warning events for given clusters
+ * Amount is limited to {@link Event.maxEventsLimit}
+ *
+ * Important! Make sure to have the parent component have clusters as a key
+ * so that component remounts when clusters change, instead of rerendering
+ * with different number of clusters
+ */
static useWarningList(clusters: string[], options?: { queryParams?: QueryParameters }) {
const queryParameters = Object.assign(
{
diff --git a/frontend/src/storybook.test.tsx b/frontend/src/storybook.test.tsx
index ceefe869d17..ab6427d7540 100644
--- a/frontend/src/storybook.test.tsx
+++ b/frontend/src/storybook.test.tsx
@@ -103,7 +103,7 @@ describe('Storybook Tests', () => {
return;
}
- describe(title, async () => {
+ describe(title, () => {
const stories = Object.entries(compose(storyFile)).map(([name, story]) => ({
name,
story,
@@ -130,6 +130,9 @@ describe('Storybook Tests', () => {
worker.events.on('request:start', onStart);
worker.events.on('request:end', onEnd);
+ act(() => {
+ previewAnnotations.queryClient.clear();
+ });
await act(async () => {
await story.run();
});
@@ -151,6 +154,12 @@ describe('Storybook Tests', () => {
}
});
+ await waitFor(() => {
+ if (previewAnnotations.queryClient.isFetching()) {
+ throw new Error('The react-query is still fetching');
+ }
+ });
+
// Cleanup listeners
worker.events.removeListener('request:start', onStart);
worker.events.removeListener('request:end', onEnd);
diff --git a/plugins/headlamp-plugin/package-lock.json b/plugins/headlamp-plugin/package-lock.json
index cd88a55f462..c90e9dc8489 100644
--- a/plugins/headlamp-plugin/package-lock.json
+++ b/plugins/headlamp-plugin/package-lock.json
@@ -47,6 +47,8 @@
"@storybook/test": "^8.2.9",
"@storybook/theming": "^8.2.9",
"@svgr/webpack": "^6.2.1",
+ "@tanstack/react-query": "^5.51.24",
+ "@tanstack/react-query-devtools": "^5.51.24",
"@testing-library/dom": "^10.1.0",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
@@ -8041,6 +8043,59 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.52.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.52.3.tgz",
+ "integrity": "sha512-+Gh7lXn+eoAsarvvnndgqBeJ5lOjup8qgQnrTsFuhNTEAo0H934DxEPro4s3TlmvITfDTJ3UDCy7kY8Azm0qsA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/query-devtools": {
+ "version": "5.52.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.52.3.tgz",
+ "integrity": "sha512-oGX9qJuNpr4vOQyeksqHr+FgLQGs5UooK87R1wTtcsUUdrRKGSgs3cBllZMtWBJxg+yVvg0TlHNGYLMjvqX3GA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.52.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.52.3.tgz",
+ "integrity": "sha512-1K7l2hkqlWuh5SdaTYPSwMmHJF5dDk5INK+EtiEwUZW4+usWTXZx7QeHuk078oSzTzaVkEFyT3VquK7F0hYkUw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.52.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@tanstack/react-query-devtools": {
+ "version": "5.52.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.52.3.tgz",
+ "integrity": "sha512-KU5OaCVOTcZF7OknXRcXXF2KIKjARe/xOa/J60Gftyic6krHQHRHjoFIbiOEH/gOQ1oV/uI59NbdQNbHim0z7w==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-devtools": "5.52.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "@tanstack/react-query": "^5.52.3",
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@tanstack/react-table": {
"version": "8.19.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.19.3.tgz",
diff --git a/plugins/headlamp-plugin/package.json b/plugins/headlamp-plugin/package.json
index bebea35b5d5..cf141032fda 100644
--- a/plugins/headlamp-plugin/package.json
+++ b/plugins/headlamp-plugin/package.json
@@ -51,6 +51,8 @@
"@storybook/test": "^8.2.9",
"@storybook/theming": "^8.2.9",
"@svgr/webpack": "^6.2.1",
+ "@tanstack/react-query": "^5.51.24",
+ "@tanstack/react-query-devtools": "^5.51.24",
"@testing-library/dom": "^10.1.0",
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
| | |