Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
},
"devDependencies": {
"@kinvolk/headlamp-plugin": "^0.14.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"vite": "^6.4.2",
"vite-plugin-svgr": "^4.5.0"
}
Expand Down
77 changes: 77 additions & 0 deletions src/components/AgonesInstallCheck.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright Contributors to Agones a Series of LF Projects, LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Grid from '@mui/material/Grid';
import Link from '@mui/material/Link';
import Typography from '@mui/material/Typography';
import React from 'react';
import { useAgonesInstalled } from '../hooks/useAgonesInstalled';

interface NotInstalledBannerProps {
isLoading?: boolean;
}

function NotInstalledBanner({ isLoading = false }: NotInstalledBannerProps) {
if (isLoading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" p={2} minHeight="200px">
<CircularProgress />
</Box>
);
}

return (
<Box display="flex" justifyContent="center" alignItems="center" p={2} minHeight="200px">
<Grid container spacing={2} direction="column" justifyContent="center" alignItems="center">
<Grid item>
<Typography variant="h5">
Agones was not detected on your cluster. If you haven&apos;t already, please install it.
</Typography>
</Grid>
<Grid item>
<Typography>
Learn how to{' '}
<Link
href="https://agones.dev/site/docs/installation/install-agones/"
target="_blank"
rel="noopener noreferrer"
>
install
</Link>{' '}
Agones
</Typography>
</Grid>
</Grid>
</Box>
);
}

interface AgonesInstallCheckProps {
children: React.ReactNode;
fallback?: React.ReactNode;
}

export function AgonesInstallCheck({ children, fallback }: AgonesInstallCheckProps) {
const { isAgonesInstalled, isAgonesCheckLoading } = useAgonesInstalled();

if (!isAgonesInstalled) {
return <>{fallback || <NotInstalledBanner isLoading={isAgonesCheckLoading} />}</>;
}

return <>{children}</>;
}
71 changes: 71 additions & 0 deletions src/hooks/useAgonesInstalled.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/*
* Copyright Contributors to Agones a Series of LF Projects, LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';

// Mock ApiProxy so the hook's internal isAgonesInstalled() call
// doesn't make real HTTP requests.
vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
ApiProxy: {
request: vi.fn(),
},
}));

import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
import { useAgonesInstalled } from './useAgonesInstalled';

describe('useAgonesInstalled', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('should start in loading state', () => {
// Never-resolving promise keeps the hook in loading state
vi.mocked(ApiProxy.request).mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => useAgonesInstalled());

expect(result.current.isAgonesInstalled).toBeNull();
expect(result.current.isAgonesCheckLoading).toBe(true);
});

it('should return isAgonesInstalled=true when Agones is detected', async () => {
vi.mocked(ApiProxy.request).mockResolvedValue({
kind: 'APIResourceList',
resources: [{ name: 'gameservers' }],
});

const { result } = renderHook(() => useAgonesInstalled());

await waitFor(() => {
expect(result.current.isAgonesInstalled).toBe(true);
});

expect(result.current.isAgonesCheckLoading).toBe(false);
});

it('should return isAgonesInstalled=false when Agones is not detected', async () => {
vi.mocked(ApiProxy.request).mockRejectedValue(new Error('404 Not Found'));

const { result } = renderHook(() => useAgonesInstalled());

await waitFor(() => {
expect(result.current.isAgonesInstalled).toBe(false);
});

expect(result.current.isAgonesCheckLoading).toBe(false);
});
});
72 changes: 72 additions & 0 deletions src/hooks/useAgonesInstalled.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/*
* Copyright Contributors to Agones a Series of LF Projects, LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
import { useEffect, useState } from 'react';

/**
* Checks whether the Agones CRDs are installed on the current cluster by
* querying the {@link https://agones.dev/site/docs/reference/agones_crd_api_reference/ | Agones API group}
* at `/apis/agones.dev/v1`.
*
* The response is validated to be a genuine Kubernetes `APIResourceList`
* (not a `Status` error object that some proxies return for 404s).
*
* @returns `true` if Agones CRDs are present, `false` otherwise.
*/
export async function isAgonesInstalled(): Promise<boolean> {
try {
const response = await ApiProxy.request('/apis/agones.dev/v1', {
method: 'GET',
});
// Verify the response is a real K8s API resource list, not an error object.
return response?.kind === 'APIResourceList' && Array.isArray(response?.resources);
} catch {
return false;
}
}

/**
* React hook that asynchronously checks whether the Agones CRDs are installed
* on the current Kubernetes cluster.
*
* @returns An object with:
* - `isAgonesInstalled` — `null` while loading, `true` if detected, `false` if not.
* - `isAgonesCheckLoading` — `true` while the API check is in progress.
*
* @example
* ```tsx
* const { isAgonesInstalled, isAgonesCheckLoading } = useAgonesInstalled();
* if (isAgonesCheckLoading) return <Spinner />;
* if (!isAgonesInstalled) return <NotInstalledBanner />;
* ```
*/
export function useAgonesInstalled() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add some documentation?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added full TSDoc documentation for the hook with a description of the return object and a usage example👍

const [isInstalled, setIsInstalled] = useState<boolean | null>(null);

useEffect(() => {
async function checkInstalled() {
const installed = await isAgonesInstalled();
setIsInstalled(!!installed);
}
checkInstalled();
}, []);

return {
isAgonesInstalled: isInstalled,
isAgonesCheckLoading: isInstalled === null,
};
}
43 changes: 36 additions & 7 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
registerSidebarEntry,
} from '@kinvolk/headlamp-plugin/lib';
import React from 'react';
import { AgonesInstallCheck } from './components/AgonesInstallCheck';
import { agonesMapSource } from './mapView';
import { FleetAutoscalerDetail } from './views/fleetautoscalers/Detail';
import { FleetAutoscalerList } from './views/fleetautoscalers/List';
Expand Down Expand Up @@ -74,47 +75,75 @@ registerRoute({
sidebar: 'agones-overview',
name: 'agones-overview',
exact: true,
component: () => <AgonesOverview />,
component: () => (
<AgonesInstallCheck>
<AgonesOverview />
</AgonesInstallCheck>
),
});

registerRoute({
path: '/agones/fleets',
sidebar: 'agones-fleets',
name: 'agones-fleets',
exact: true,
component: () => <FleetList />,
component: () => (
<AgonesInstallCheck>
<FleetList />
</AgonesInstallCheck>
),
});
registerRoute({
path: '/agones/fleets/:namespace/:name',
sidebar: 'agones-fleets',
name: 'agones-fleet',
component: () => <FleetDetail />,
component: () => (
<AgonesInstallCheck>
<FleetDetail />
</AgonesInstallCheck>
),
});

registerRoute({
path: '/agones/gameservers',
sidebar: 'agones-gameservers',
name: 'agones-gameservers',
exact: true,
component: () => <GameServerList />,
component: () => (
<AgonesInstallCheck>
<GameServerList />
</AgonesInstallCheck>
),
});
registerRoute({
path: '/agones/gameservers/:namespace/:name',
sidebar: 'agones-gameservers',
name: 'agones-gameserver',
component: () => <GameServerDetail />,
component: () => (
<AgonesInstallCheck>
<GameServerDetail />
</AgonesInstallCheck>
),
});

registerRoute({
path: '/agones/fleetautoscalers',
sidebar: 'agones-fleetautoscalers',
name: 'agones-fleetautoscalers',
exact: true,
component: () => <FleetAutoscalerList />,
component: () => (
<AgonesInstallCheck>
<FleetAutoscalerList />
</AgonesInstallCheck>
),
});
registerRoute({
path: '/agones/fleetautoscalers/:namespace/:name',
sidebar: 'agones-fleetautoscalers',
name: 'agones-fleetautoscaler',
component: () => <FleetAutoscalerDetail />,
component: () => (
<AgonesInstallCheck>
<FleetAutoscalerDetail />
</AgonesInstallCheck>
),
});
Loading