diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index f7d0d885658..73f74014a6a 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -4,6 +4,7 @@ import { initialize, mswLoader } from 'msw-storybook-addon'; import '../src/index.css'; import { Title, Subtitle, Description, Primary, Controls } from '@storybook/blocks'; import { baseMocks } from './baseMocks'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; // https://github.com/mswjs/msw-storybook-addon initialize({ @@ -11,13 +12,26 @@ initialize({ waitUntilReady: true, }); +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnMount: 'always', + staleTime: 0, + retry: false, + gcTime: 0, + }, + }, +}); + const withThemeProvider = (Story: any, context: any) => { const theme = themesConf[context.globals.backgrounds?.value === '#1f1f1f' ? 'dark' : 'light']; const ourThemeProvider = ( - - - + + + + + ); return ourThemeProvider; }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e9cd6bb9dea..7f13daf08e1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,8 @@ "@mui/x-tree-view": "^6.17.0", "@reduxjs/toolkit": "^1.9.3", "@storybook/blocks": "^8.2.9", + "@tanstack/react-query": "^5.51.24", + "@tanstack/react-query-devtools": "^5.51.24", "@testing-library/dom": "^10.1.0", "@testing-library/react": "^16.0.0", "@types/glob": "^8.1.0", @@ -5403,6 +5405,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/frontend/package.json b/frontend/package.json index df526cba1bc..a0c725c94eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,6 +25,8 @@ "@mui/x-tree-view": "^6.17.0", "@reduxjs/toolkit": "^1.9.3", "@storybook/blocks": "^8.2.9", + "@tanstack/react-query": "^5.51.24", + "@tanstack/react-query-devtools": "^5.51.24", "@testing-library/dom": "^10.1.0", "@testing-library/react": "^16.0.0", "@types/glob": "^8.1.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1482dbf49d1..8b5c688efe5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,7 @@ import './i18n/config'; import './components/App/icons'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import React from 'react'; import { I18nextProvider } from 'react-i18next'; import { Provider } from 'react-redux'; @@ -29,13 +31,27 @@ function AppWithRedux(props: React.PropsWithChildren<{}>) { ); } +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 3 * 60_000, + refetchOnWindowFocus: false, + }, + }, +}); +const queryDevtoolsEnabled = false; + function App() { return ( }> - - - + + {queryDevtoolsEnabled && } + + + + + ); diff --git a/frontend/src/components/App/Home/index.tsx b/frontend/src/components/App/Home/index.tsx index efb0407cfa0..fc41d1b3578 100644 --- a/frontend/src/components/App/Home/index.tsx +++ b/frontend/src/components/App/Home/index.tsx @@ -167,7 +167,7 @@ export default function Home() { return null; } - return ; + return ; } interface HomeComponentProps { diff --git a/frontend/src/components/Sidebar/VersionButton.tsx b/frontend/src/components/Sidebar/VersionButton.tsx index f9fe8126d78..87344073f98 100644 --- a/frontend/src/components/Sidebar/VersionButton.tsx +++ b/frontend/src/components/Sidebar/VersionButton.tsx @@ -6,6 +6,7 @@ import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import { styled, useTheme } from '@mui/system'; +import { useQuery } from '@tanstack/react-query'; import { useSnackbar } from 'notistack'; import React from 'react'; import { useTranslation } from 'react-i18next'; @@ -27,7 +28,6 @@ const VersionIcon = styled(Icon)({ export default function VersionButton() { const isSidebarOpen = useTypedSelector(state => state.sidebar.isSidebarOpen); const { enqueueSnackbar } = useSnackbar(); - const [clusterVersion, setClusterVersion] = React.useState(null); const cluster = useCluster(); const theme = useTheme(); const [open, setOpen] = React.useState(false); @@ -62,68 +62,43 @@ export default function VersionButton() { ]; } - React.useEffect( - () => { - let stillAlive = true; - function fetchVersion() { - getVersion() - .then((results: StringDict) => { - if (!stillAlive) { - return; - } - - setClusterVersion(results); - let versionChange = 0; - if (clusterVersion && results && results.gitVersion) { - versionChange = semver.compare(results.gitVersion, clusterVersion.gitVersion); + const { data: clusterVersion } = useQuery({ + placeholderData: null as any, + queryKey: ['version', cluster ?? ''], + queryFn: () => { + return getVersion() + .then((results: StringDict) => { + let versionChange = 0; + if (clusterVersion && results && results.gitVersion) { + versionChange = semver.compare(results.gitVersion, clusterVersion.gitVersion); - let msg = ''; - if (versionChange > 0) { - msg = t('translation|Cluster version upgraded to {{ gitVersion }}', { - gitVersion: results.gitVersion, - }); - } else if (versionChange < 0) { - msg = t('translation|Cluster version downgraded to {{ gitVersion }}', { - gitVersion: results.gitVersion, - }); - } - - if (msg) { - enqueueSnackbar(msg, { - key: 'version', - preventDuplicate: true, - autoHideDuration: versionSnackbarHideTimeout, - variant: 'info', - }); - } + let msg = ''; + if (versionChange > 0) { + msg = t('translation|Cluster version upgraded to {{ gitVersion }}', { + gitVersion: results.gitVersion, + }); + } else if (versionChange < 0) { + msg = t('translation|Cluster version downgraded to {{ gitVersion }}', { + gitVersion: results.gitVersion, + }); } - }) - .catch((error: Error) => console.error('Getting the cluster version:', error)); - } - - if (!clusterVersion) { - fetchVersion(); - } - const intervalHandler = setInterval(() => { - fetchVersion(); - }, versionFetchInterval); + if (msg) { + enqueueSnackbar(msg, { + key: 'version', + preventDuplicate: true, + autoHideDuration: versionSnackbarHideTimeout, + variant: 'info', + }); + } + } - return function cleanup() { - stillAlive = false; - clearInterval(intervalHandler); - }; + return results; + }) + .catch((error: Error) => console.error('Getting the cluster version:', error)); }, - // eslint-disable-next-line - [clusterVersion] - ); - - // Use the location to make sure the version is changed, as it depends on the cluster - // (defined in the URL ATM). - // @todo: Update this if the active cluster management is changed. - React.useEffect(() => { - setClusterVersion(null); - }, [cluster]); + refetchInterval: versionFetchInterval, + }); function handleClose() { setOpen(false); diff --git a/frontend/src/components/cluster/__snapshots__/Overview.Events.stories.storyshot b/frontend/src/components/cluster/__snapshots__/Overview.Events.stories.storyshot index 91b2bc19739..f09b9b4dfbd 100644 --- a/frontend/src/components/cluster/__snapshots__/Overview.Events.stories.storyshot +++ b/frontend/src/components/cluster/__snapshots__/Overview.Events.stories.storyshot @@ -91,7 +91,7 @@ + + - - - diff --git a/frontend/src/components/common/NamespacesAutocomplete.tsx b/frontend/src/components/common/NamespacesAutocomplete.tsx index e5d0b0c421a..4c08568fe3b 100644 --- a/frontend/src/components/common/NamespacesAutocomplete.tsx +++ b/frontend/src/components/common/NamespacesAutocomplete.tsx @@ -5,7 +5,7 @@ import Checkbox from '@mui/material/Checkbox'; import { useTheme } from '@mui/material/styles'; import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; -import React from 'react'; +import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { useHistory, useLocation } from 'react-router-dom'; @@ -154,11 +154,11 @@ export function NamespacesAutocomplete() { function NamespacesFromClusterAutocomplete( props: Omit ) { - const [namespaceNames, setNamespaceNames] = React.useState([]); - - Namespace.useApiList((namespaces: Namespace[]) => { - setNamespaceNames(namespaces.map(namespace => namespace.metadata.name)); - }); + const [namespacesList] = Namespace.useList(); + const namespaceNames = useMemo( + () => namespacesList?.map(namespace => namespace.metadata.name) ?? [], + [namespacesList] + ); return ; } diff --git a/frontend/src/components/common/Resource/AuthVisible.tsx b/frontend/src/components/common/Resource/AuthVisible.tsx index 1f926946829..170b4e4a470 100644 --- a/frontend/src/components/common/Resource/AuthVisible.tsx +++ b/frontend/src/components/common/Resource/AuthVisible.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import React, { useEffect } from 'react'; import { KubeObject } from '../../../lib/k8s/cluster'; export interface AuthVisibleProps extends React.PropsWithChildren<{}> { @@ -25,40 +26,29 @@ export interface AuthVisibleProps extends React.PropsWithChildren<{}> { */ export default function AuthVisible(props: AuthVisibleProps) { const { item, authVerb, subresource, namespace, onError, onAuthResult, children } = props; - const [visible, setVisible] = React.useState(false); + const { data } = useQuery({ + enabled: !!item, + queryKey: ['authVisible', item, authVerb, subresource, namespace], + queryFn: async () => { + try { + const res = await item.getAuthorization(authVerb, { subresource, namespace }); + return res; + } catch (e: any) { + onError?.(e); + } + }, + }); - React.useEffect(() => { - let isMounted = true; - if (!!item) { - item - .getAuthorization(authVerb, { subresource, namespace }) - .then((result: any) => { - if (isMounted) { - if (result.status?.allowed !== visible) { - setVisible(!!result.status?.allowed); - } - if (!!onAuthResult) { - onAuthResult({ - allowed: result.status?.allowed, - reason: result.status?.reason || '', - }); - } - } - }) - .catch((err: Error) => { - if (isMounted) { - if (!!onError) { - onError(err); - } - setVisible(false); - } - }); - } + const visible = data?.status?.allowed ?? false; - return function cleanup() { - isMounted = false; - }; - }, [item]); + useEffect(() => { + if (data) { + onAuthResult?.({ + allowed: visible, + reason: data.status?.reason ?? '', + }); + } + }, [data]); if (!visible) { return null; diff --git a/frontend/src/components/common/Resource/PortForward.tsx b/frontend/src/components/common/Resource/PortForward.tsx index 33b554ca808..dc641c8ab75 100644 --- a/frontend/src/components/common/Resource/PortForward.tsx +++ b/frontend/src/components/common/Resource/PortForward.tsx @@ -6,17 +6,18 @@ import MuiLink from '@mui/material/Link'; import React from 'react'; import { useTranslation } from 'react-i18next'; import helpers from '../../../helpers'; +import { PortForward as PortForwardState } from '../../../lib/k8s/api/v1/portForward'; import { listPortForward, startPortForward, stopOrDeletePortForward, } from '../../../lib/k8s/apiProxy'; -import { PortForward as PortForwardState } from '../../../lib/k8s/apiProxy/portForward'; import { KubeContainer, KubeObject } from '../../../lib/k8s/cluster'; import Pod from '../../../lib/k8s/pod'; import Service from '../../../lib/k8s/service'; import { getCluster } from '../../../lib/util'; import ActionButton from '../ActionButton'; +export { type PortForward as PortForwardState } from '../../../lib/k8s/api/v1/portForward'; interface PortForwardProps { containerPort: number | string; @@ -70,7 +71,7 @@ function checkIfPodPortForwarding(portforwardParam: { ); } -export default function PortForward(props: PortForwardProps) { +function PortForwardContent(props: PortForwardProps) { const { containerPort, resource } = props; const isPod = resource?.kind !== 'Service'; const service = !isPod ? (resource as Service) : undefined; @@ -155,10 +156,10 @@ export default function PortForward(props: PortForwardProps) { setError(null); const resourceName = name || ''; - const podNamespace = isPod ? namespace : pods[0].metadata.namespace; + const podNamespace = isPod ? namespace : pods![0].metadata.namespace; const serviceNamespace = namespace; const serviceName = !isPod ? resourceName : ''; - const podName = isPod ? resourceName : pods[0].metadata.name; + const podName = isPod ? resourceName : pods![0].metadata.name; var port = portForward?.port; let address = 'localhost'; @@ -356,3 +357,9 @@ export default function PortForward(props: PortForwardProps) { ); } + +export default function PortForward(props: PortForwardProps) { + if (!helpers.isElectron()) return null; + + return ; +} diff --git a/frontend/src/components/common/Resource/ResourceTable.stories.tsx b/frontend/src/components/common/Resource/ResourceTable.stories.tsx index 76ffc866872..207a099070f 100644 --- a/frontend/src/components/common/Resource/ResourceTable.stories.tsx +++ b/frontend/src/components/common/Resource/ResourceTable.stories.tsx @@ -1,5 +1,6 @@ import { configureStore } from '@reduxjs/toolkit'; import { Meta, StoryFn } from '@storybook/react'; +import { useMockListQuery } from '../../../helpers/testHelpers'; import Pod, { KubePod } from '../../../lib/k8s/pod'; import { INITIAL_STATE as UI_INITIAL_STATE } from '../../../redux/reducers/ui'; import { TestContext } from '../../../test'; @@ -51,71 +52,67 @@ const TemplateWithFilter: StoryFn<{ }; class MyPod extends Pod { - static useList = () => + static useList = useMockListQuery.data( [ - [ - { - kind: 'Pod', - apiVersion: 'v1', - metadata: { - name: 'mypod0', - uid: 'phony0', - creationTimestamp: '2021-12-15T14:57:13Z', - resourceVersion: '1', - selfLink: '0', - namespace: 'MyNamespace0', - }, + { + kind: 'Pod', + apiVersion: 'v1', + metadata: { + name: 'mypod0', + uid: 'phony0', + creationTimestamp: '2021-12-15T14:57:13Z', + resourceVersion: '1', + selfLink: '0', + namespace: 'MyNamespace0', }, - { - kind: 'Pod', - apiVersion: 'v1', - metadata: { - name: 'mypod1', - uid: 'phony1', - creationTimestamp: '2021-12-15T14:57:13Z', - resourceVersion: '1', - selfLink: '1', - namespace: 'MyNamespace1', - labels: { - mylabel1: 'myvalue1', - }, + }, + { + kind: 'Pod', + apiVersion: 'v1', + metadata: { + name: 'mypod1', + uid: 'phony1', + creationTimestamp: '2021-12-15T14:57:13Z', + resourceVersion: '1', + selfLink: '1', + namespace: 'MyNamespace1', + labels: { + mylabel1: 'myvalue1', }, }, - { - kind: 'Pod', - apiVersion: 'v1', - metadata: { - name: 'mypod2', - uid: 'phony2', - creationTimestamp: '2021-12-15T14:57:13Z', - resourceVersion: '1', - selfLink: '2', - namespace: 'MyNamespace2', - labels: { - mykey2: 'mylabel', - }, + }, + { + kind: 'Pod', + apiVersion: 'v1', + metadata: { + name: 'mypod2', + uid: 'phony2', + creationTimestamp: '2021-12-15T14:57:13Z', + resourceVersion: '1', + selfLink: '2', + namespace: 'MyNamespace2', + labels: { + mykey2: 'mylabel', }, }, - { - kind: 'Pod', - apiVersion: 'v1', - metadata: { - name: 'mypod3', - uid: 'phony3', - creationTimestamp: '2021-12-15T14:57:13Z', - resourceVersion: '1', - selfLink: '3', - namespace: 'MyNamespace3', - labels: { - mykey3: 'myvalue3', - }, + }, + { + kind: 'Pod', + apiVersion: 'v1', + metadata: { + name: 'mypod3', + uid: 'phony3', + creationTimestamp: '2021-12-15T14:57:13Z', + resourceVersion: '1', + selfLink: '3', + namespace: 'MyNamespace3', + labels: { + mykey3: 'myvalue3', }, }, - ].map(pod => new Pod(pod as KubePod)), - null, - () => {}, - () => {}, - ] as any; + }, + ].map(pod => new Pod(pod as KubePod)) + ); } const podData: ResourceTableFromResourceClassProps = { diff --git a/frontend/src/components/common/Terminal.tsx b/frontend/src/components/common/Terminal.tsx index bedcab1e728..310ff5f19de 100644 --- a/frontend/src/components/common/Terminal.tsx +++ b/frontend/src/components/common/Terminal.tsx @@ -262,7 +262,7 @@ export default function Terminal(props: TerminalProps) { execOrAttachRef.current = await item.attach( container, - (items: ArrayBuffer) => onData(xtermRef.current!, items), + items => onData(xtermRef.current!, items), { failCb: () => shellConnectFailed(xtermRef.current!) } ); } else { @@ -272,7 +272,7 @@ export default function Terminal(props: TerminalProps) { execOrAttachRef.current = await item.exec( container, - (items: ArrayBuffer) => onData(xtermRef.current!, items), + items => onData(xtermRef.current!, items), { command: [command], failCb: () => shellConnectFailed(xtermRef.current!) } ); } diff --git a/frontend/src/components/configmap/__snapshots__/List.Items.stories.storyshot b/frontend/src/components/configmap/__snapshots__/List.Items.stories.storyshot index 8d0f8806269..712922d258b 100644 --- a/frontend/src/components/configmap/__snapshots__/List.Items.stories.storyshot +++ b/frontend/src/components/configmap/__snapshots__/List.Items.stories.storyshot @@ -558,6 +558,51 @@ + + + my-pvc + + + + + default + + + + 3 + + +

+ 3mo +

+ + + - 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
+ + + my-pvc + + + + + default + + + + bla + + + 0 + + +

+ 3mo +

+ + + - 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",