diff --git a/package-lock.json b/package-lock.json index 16aacf3..a891d67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4" }, "devDependencies": { "@types/node": "^24.13.3", @@ -676,6 +677,19 @@ } } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1125,6 +1139,44 @@ "react": "^19.2.8" } }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/rolldown": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", @@ -1165,6 +1217,12 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index ac7eddb..975af23 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4" }, "devDependencies": { "@types/node": "^24.13.3", diff --git a/src/App.css b/src/App.css index c018f89..70e4321 100644 --- a/src/App.css +++ b/src/App.css @@ -484,6 +484,8 @@ } .cluster-node-card { + display: block; + text-decoration: none; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); @@ -493,6 +495,16 @@ overflow-wrap: anywhere; } +.cluster-node-card:hover { + border-color: var(--accent-border); + background: var(--accent-bg); +} + +.cluster-node-card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + .cluster-list .secondary-button { width: 100%; justify-content: flex-start; diff --git a/src/App.tsx b/src/App.tsx index cd5375a..fe16100 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { Link, Navigate, Route, Routes, useMatch, useNavigate, useParams } from 'react-router-dom' import './App.css' const API_BASE_URL = import.meta.env.VITE_API_BASE_URL @@ -254,8 +255,24 @@ const createCluster = async (payload: CreateClusterPayload) => { } function App() { + return ( + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ) +} + +function Workspace({ page: activePage }: { page: PageId | 'node-details' }) { + const navigate = useNavigate() + const { clusterId, nodeId } = useParams() + const isClusterPage = Boolean(useMatch('/clusters/*')) const [isCollapsed, setIsCollapsed] = useState(false) - const [activePage, setActivePage] = useState(getSavedPage) const [theme, setTheme] = useState(getSavedTheme) const [isRegisterFormOpen, setIsRegisterFormOpen] = useState(false) const [isClusterFormOpen, setIsClusterFormOpen] = useState(false) @@ -304,21 +321,33 @@ function App() { } } - const handleClusterClick = async (cluster: Cluster) => { + const handleClusterClick = (cluster: Cluster) => { setSelectedCluster(cluster) + navigate(`/clusters/${encodeURIComponent(cluster.uuid)}`) + } + + useEffect(() => { + if (!clusterId) return + let cancelled = false + setClusterDetails(null) setClusterNodesStatus('loading') setClusterNodesError('') - try { - const response = await getClusterResource(`cluster-nodes/${encodeURIComponent(cluster.uuid)}`) - setClusterDetails(await response.json() as ClusterNodesResponse) - setClusterNodesStatus('success') - selectPage('cluster-details') - } catch (error) { - setClusterNodesStatus('error') - setClusterNodesError(error instanceof Error ? error.message : 'Failed to load cluster nodes') - } - } + void getClusterResource(`cluster-nodes/${encodeURIComponent(clusterId)}`) + .then((response) => response.json() as Promise) + .then((details) => { + if (cancelled) return + setClusterDetails(details) + setClusterNodesStatus('success') + }) + .catch((error: unknown) => { + if (cancelled) return + setClusterNodesStatus('error') + setClusterNodesError(error instanceof Error ? error.message : 'Failed to load cluster nodes') + }) + + return () => { cancelled = true } + }, [clusterId]) const loadNodeInterfaces = async () => { setNodesStatus('loading') @@ -355,7 +384,7 @@ function App() { }, [activePage]) const selectPage = (page: PageId) => { - setActivePage(page) + navigate(`/${page}`) savePage(page) } @@ -531,10 +560,10 @@ function App() { + + )} + + )} + {activePage === 'node-details' && clusterDetails && ( +
+
+

{clusterDetails.nodes.find((node) => node.uuid === nodeId)?.node_name ?? 'Node not found'}

+ +
+
+ )} {activePage === 'cluster-details' && clusterDetails && (
@@ -577,8 +633,13 @@ function App() { {clusterDetails.nodes.length > 0 ? (
    {clusterDetails.nodes.map((node) => ( -
  • - {node.node_name} +
  • + + {node.node_name} +
  • ))}
diff --git a/src/main.tsx b/src/main.tsx index bef5202..4ec341e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,13 @@ import { StrictMode } from 'react' +import { BrowserRouter } from 'react-router-dom' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( - + + + , )