diff --git a/package-lock.json b/package-lock.json
index c8b7f36..50eaafa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,8 @@
"version": "0.1.0",
"devDependencies": {
"@kinvolk/headlamp-plugin": "^0.14.0",
+ "@testing-library/dom": "^10.4.1",
+ "@testing-library/react": "^16.3.2",
"vite": "^6.4.3",
"vite-plugin-svgr": "^4.5.0"
}
@@ -4060,7 +4062,6 @@
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4103,11 +4104,10 @@
"license": "MIT"
},
"node_modules/@testing-library/react": {
- "version": "16.3.0",
- "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz",
- "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==",
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
diff --git a/package.json b/package.json
index 633c7e3..2ac1671 100644
--- a/package.json
+++ b/package.json
@@ -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.3",
"vite-plugin-svgr": "^4.5.0"
}
diff --git a/src/components/AgonesInstallCheck.tsx b/src/components/AgonesInstallCheck.tsx
new file mode 100644
index 0000000..3a4364b
--- /dev/null
+++ b/src/components/AgonesInstallCheck.tsx
@@ -0,0 +1,96 @@
+/*
+ * 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 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';
+
+function NotInstalledBanner() {
+ return (
+
+
+
+
+ Agones was not detected on your cluster. If you haven't already, please install it.
+
+
+
+
+ Learn how to{' '}
+
+ install
+ {' '}
+ Agones
+
+
+
+
+ );
+}
+
+interface AgonesInstallCheckProps {
+ children: React.ReactNode;
+ fallback?: React.ReactNode;
+}
+
+/**
+ * Wrapper component that gates its children behind an Agones CRD detection
+ * check. While the check is in flight nothing is rendered; if the CRDs are
+ * absent a {@link NotInstalledBanner} (or a custom `fallback`) is rendered
+ * instead of the children.
+ */
+export function AgonesInstallCheck({ children, fallback }: AgonesInstallCheckProps) {
+ const { isAgonesInstalled } = useAgonesInstalled();
+
+ if (isAgonesInstalled === null) {
+ return null;
+ }
+
+ if (isAgonesInstalled === false) {
+ return <>{fallback || }>;
+ }
+
+ return <>{children}>;
+}
+
+/**
+ * Higher-order component that wraps `Component` with {@link AgonesInstallCheck}.
+ *
+ * Use this instead of manually nesting `` around every
+ * route component — it keeps `registerRoute` calls concise and ensures
+ * the guard is applied consistently.
+ *
+ * @example
+ * ```tsx
+ * registerRoute({
+ * path: '/agones',
+ * component: withInstallCheck(AgonesOverview),
+ * });
+ * ```
+ */
+export const withInstallCheck = (Component: React.ComponentType) => () =>
+ (
+
+
+
+ );
diff --git a/src/hooks/useAgonesInstalled.test.tsx b/src/hooks/useAgonesInstalled.test.tsx
new file mode 100644
index 0000000..203e4f1
--- /dev/null
+++ b/src/hooks/useAgonesInstalled.test.tsx
@@ -0,0 +1,70 @@
+/*
+ * 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 { renderHook, waitFor } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { useAgonesInstalled } from './useAgonesInstalled';
+
+// 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(),
+ },
+}));
+
+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);
+ });
+});
diff --git a/src/hooks/useAgonesInstalled.tsx b/src/hooks/useAgonesInstalled.tsx
new file mode 100644
index 0000000..f117aa3
--- /dev/null
+++ b/src/hooks/useAgonesInstalled.tsx
@@ -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 {
+ 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 ;
+ * if (!isAgonesInstalled) return ;
+ * ```
+ */
+export function useAgonesInstalled() {
+ const [isInstalled, setIsInstalled] = useState(null);
+
+ useEffect(() => {
+ async function checkInstalled() {
+ const installed = await isAgonesInstalled();
+ setIsInstalled(!!installed);
+ }
+ checkInstalled();
+ }, []);
+
+ return {
+ isAgonesInstalled: isInstalled,
+ isAgonesCheckLoading: isInstalled === null,
+ };
+}
diff --git a/src/index.tsx b/src/index.tsx
index 6f9e55e..f7a7d88 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -19,7 +19,7 @@ import {
registerRoute,
registerSidebarEntry,
} from '@kinvolk/headlamp-plugin/lib';
-import React from 'react';
+import { withInstallCheck } from './components/AgonesInstallCheck';
import { agonesMapSource } from './mapView';
import { FleetAutoscalerDetail } from './views/fleetautoscalers/Detail';
import { FleetAutoscalerList } from './views/fleetautoscalers/List';
@@ -74,7 +74,7 @@ registerRoute({
sidebar: 'agones-overview',
name: 'agones-overview',
exact: true,
- component: () => ,
+ component: withInstallCheck(AgonesOverview),
});
registerRoute({
@@ -82,13 +82,13 @@ registerRoute({
sidebar: 'agones-fleets',
name: 'agones-fleets',
exact: true,
- component: () => ,
+ component: withInstallCheck(FleetList),
});
registerRoute({
path: '/agones/fleets/:namespace/:name',
sidebar: 'agones-fleets',
name: 'agones-fleet',
- component: () => ,
+ component: withInstallCheck(FleetDetail),
});
registerRoute({
@@ -96,13 +96,13 @@ registerRoute({
sidebar: 'agones-gameservers',
name: 'agones-gameservers',
exact: true,
- component: () => ,
+ component: withInstallCheck(GameServerList),
});
registerRoute({
path: '/agones/gameservers/:namespace/:name',
sidebar: 'agones-gameservers',
name: 'agones-gameserver',
- component: () => ,
+ component: withInstallCheck(GameServerDetail),
});
registerRoute({
@@ -110,11 +110,11 @@ registerRoute({
sidebar: 'agones-fleetautoscalers',
name: 'agones-fleetautoscalers',
exact: true,
- component: () => ,
+ component: withInstallCheck(FleetAutoscalerList),
});
registerRoute({
path: '/agones/fleetautoscalers/:namespace/:name',
sidebar: 'agones-fleetautoscalers',
name: 'agones-fleetautoscaler',
- component: () => ,
+ component: withInstallCheck(FleetAutoscalerDetail),
});
diff --git a/src/isAgonesInstalled.test.ts b/src/isAgonesInstalled.test.ts
new file mode 100644
index 0000000..5d14e22
--- /dev/null
+++ b/src/isAgonesInstalled.test.ts
@@ -0,0 +1,108 @@
+/*
+ * 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 { describe, expect, it, vi } from 'vitest';
+
+// Mock ApiProxy before importing the module under test
+vi.mock('@kinvolk/headlamp-plugin/lib', () => ({
+ ApiProxy: {
+ request: vi.fn(),
+ },
+}));
+
+import { ApiProxy } from '@kinvolk/headlamp-plugin/lib';
+import { isAgonesInstalled } from './hooks/useAgonesInstalled';
+
+describe('isAgonesInstalled', () => {
+ it('should return true when the Agones API group responds with a valid APIResourceList', async () => {
+ vi.mocked(ApiProxy.request).mockResolvedValue({
+ kind: 'APIResourceList',
+ resources: [{ name: 'gameservers' }],
+ });
+
+ const result = await isAgonesInstalled();
+
+ expect(result).toBe(true);
+ expect(ApiProxy.request).toHaveBeenCalledWith('/apis/agones.dev/v1', {
+ method: 'GET',
+ });
+ });
+
+ it('should return true when resources array is empty but valid', async () => {
+ vi.mocked(ApiProxy.request).mockResolvedValue({
+ kind: 'APIResourceList',
+ resources: [],
+ });
+
+ const result = await isAgonesInstalled();
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false when the API call throws (Agones not installed)', async () => {
+ vi.mocked(ApiProxy.request).mockRejectedValue(new Error('404 Not Found'));
+
+ const result = await isAgonesInstalled();
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false on network errors', async () => {
+ vi.mocked(ApiProxy.request).mockRejectedValue(new Error('Network Error'));
+
+ const result = await isAgonesInstalled();
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false when the response is null', async () => {
+ vi.mocked(ApiProxy.request).mockResolvedValue(null);
+
+ const result = await isAgonesInstalled();
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false when the response is undefined', async () => {
+ vi.mocked(ApiProxy.request).mockResolvedValue(undefined);
+
+ const result = await isAgonesInstalled();
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false when response is a non-APIResourceList object (error object)', async () => {
+ vi.mocked(ApiProxy.request).mockResolvedValue({
+ kind: 'Status',
+ status: 'Failure',
+ message: 'the server could not find the requested resource',
+ });
+
+ const result = await isAgonesInstalled();
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false when response has no resources field', async () => {
+ vi.mocked(ApiProxy.request).mockResolvedValue({
+ kind: 'APIResourceList',
+ });
+
+ const result = await isAgonesInstalled();
+
+ expect(result).toBe(false);
+ });
+});