diff --git a/.dockerignore b/.dockerignore index 814e90a9..3f83d039 100644 --- a/.dockerignore +++ b/.dockerignore @@ -12,4 +12,5 @@ helm-charts .editorconfig .idea coverage* -# adopted from https://bun.sh/guides/ecosystem/docker \ No newline at end of file +# adopted from https://bun.sh/guides/ecosystem/docker +.env.development diff --git a/.env b/.env index 8be0764a..dc326a1f 100644 --- a/.env +++ b/.env @@ -8,4 +8,5 @@ VITE_DNS_URL="https://dns.cloud.cbh.kth.se" VITE_MAIA_URL="https://maia.app.cloud.cbh.kth.se/maia" # can be comma separated to add more VITE_SERVER_PLATFORM="linux/amd64" +VITE_DEPLOYMENT_SSH_BASE="deploy.cloud.cbh.kth.se" GENERATE_SOURCEMAP=false diff --git a/.github/workflows/ts-format-validate.yml b/.github/workflows/ts-format-validate.yml index 04303c0a..b616dacd 100644 --- a/.github/workflows/ts-format-validate.yml +++ b/.github/workflows/ts-format-validate.yml @@ -7,17 +7,17 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v4 - - name: Setup Bun - uses: oven-sh/setup-bun@v1 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 - - name: Install Prettier - run: bun install + - name: Install Prettier + run: bun install - - name: Check for formatting errors - run: bun run format-check - - - name: Check for TS errors - run: bun run tsc + - name: Check for formatting errors + run: bun run format-check + + - name: Check for TS errors + run: bun run tsc --noEmit diff --git a/.gitignore b/.gitignore index 86c7bf2e..32a8605f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,9 +25,10 @@ yarn-error.log* # .env.dev +.env.development # ide .vscode # npm package-lock.json (we use Bun) -package-lock.json \ No newline at end of file +package-lock.json diff --git a/Dockerfile b/Dockerfile index 4005e58e..f438c183 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,7 @@ ENV DNS_URL="https://dns.cloud.cbh.kth.se" ENV MAIA_URL="https://maia.app.cloud.cbh.kth.se/maia" # can be comma separated to add more ENV SERVER_PLATFORM="linux/amd64" +ENV DEPLOYMENT_SSH_BASE="deploy.cloud.cbh.kth.se" EXPOSE 3000 ENTRYPOINT ["/entrypoint.sh"] diff --git a/bun.lockb b/bun.lockb index 29e9ecf8..8ed9178d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index b8ba10cd..cb4f95a6 100644 --- a/package.json +++ b/package.json @@ -9,70 +9,74 @@ "dev": "vite --port 3000", "build": "vite build", "format": "prettier --write .", - "format-check": "prettier --check ." + "format-check": "prettier --check .", + "check": "prettier --check . && tsc --noEmit" }, "dependencies": { - "@emotion/react": "^11.11.4", - "@emotion/styled": "^11.11.5", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", "@iconify/react": "^4.1.1", - "@kthcloud/go-deploy-types": "^1.0.24", - "@mui/icons-material": "^5.15.20", - "@mui/lab": "^5.0.0-alpha.170", - "@mui/material": "^5.15.20", + "@kthcloud/go-deploy-types": "^1.0.25", + "@mui/icons-material": "^7.3.9", + "@mui/lab": "^5.0.0-alpha.177", + "@mui/material": "^7.3.9", "@mui/material-next": "^6.0.0-alpha.126", - "@mui/x-tree-view": "^7.7.0", - "@react-keycloak/web": "^3.4.0", - "@react-three/fiber": "^8.16.8", + "@mui/x-tree-view": "^7.29.10", + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", "@sanity/eventsource": "^5.0.2", "@types/crypto-js": "^4.2.2", + "@types/lodash": "^4.17.24", "@types/numeral": "^2.0.5", "@types/punycode": "^2.1.4", "@types/three": "^0.164.1", - "apexcharts": "^3.49.1", - "bun": "^1.2.22", + "apexcharts": "^3.54.1", + "bun": "^1.3.10", "change-case": "^5.4.4", "crypto-js": "^4.2.0", "http-status-codes": "^2.3.0", - "i18next": "^23.11.5", - "i18next-browser-languagedetector": "^7.2.1", - "js-base64": "^3.7.7", - "keycloak-js": "^24.0.5", - "lodash": "^4.17.21", + "i18next": "^23.16.8", + "i18next-browser-languagedetector": "^7.2.2", + "js-base64": "^3.7.8", + "lodash": "^4.17.23", "million": "latest", - "notistack": "^3.0.1", + "notistack": "^3.0.2", "numeral": "^2.0.6", + "oidc-client-ts": "^3.4.1", "punycode": "^2.3.1", - "react": "^18.3.1", - "react-apexcharts": "^1.4.1", - "react-cookie": "^7.1.4", - "react-copy-to-clipboard": "^5.1.0", - "react-dom": "^18.3.1", + "react": "^19.2.4", + "react-apexcharts": "^1.9.0", + "react-cookie": "^7.2.2", + "react-copy-to-clipboard": "^5.1.1", + "react-dom": "^19.2.4", "react-helmet-async": "^2.0.5", - "react-i18next": "^14.1.2", - "react-router-dom": "^6.23.1", - "simplebar": "^6.2.7", - "simplebar-react": "^3.2.6", + "react-i18next": "^14.1.3", + "react-oidc-context": "^3.3.0", + "react-router-dom": "^6.30.3", + "simplebar": "^6.3.3", + "simplebar-react": "^3.3.2", "three": "^0.164.1", - "three-stdlib": "^2.30.3", - "yaml": "^2.4.5" + "three-stdlib": "^2.36.1", + "yaml": "^2.8.2" }, "devDependencies": { "@faker-js/faker": "^8.4.1", - "@types/bun": "^1.1.17", - "@types/react": "^18.3.3", + "@types/bun": "^1.3.10", + "@types/react": "^18.3.28", "@types/react-copy-to-clipboard": "^5.0.7", - "@types/react-dom": "^18.3.0", - "@typescript-eslint/eslint-plugin": "^7.13.0", - "@typescript-eslint/parser": "^7.13.0", - "@vitejs/plugin-react": "^4.3.1", - "@vitejs/plugin-react-swc": "^3.7.0", - "eslint": "^9.35.0", - "eslint-plugin-react": "^7.34.2", + "@types/react-dom": "^18.3.7", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "@vitejs/plugin-react": "^5.1.1", + "@vitejs/plugin-react-swc": "^3.11.0", + "babel-plugin-react-compiler": "^1.0.0", + "eslint": "^9.39.4", + "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^4.6.2", - "eslint-plugin-react-refresh": "^0.4.7", - "prettier": "^3.6.2", + "eslint-plugin-react-refresh": "^0.4.26", + "prettier": "^3.8.1", "prettier-plugin-nginx": "^1.0.3", - "typescript": "^5.4.5", - "vite": "^7.1.6" + "typescript": "^5.9.3", + "vite": "^7.3.1" } } diff --git a/public/static/models/Brain.glb b/public/static/models/Brain.glb deleted file mode 100644 index 427173d9..00000000 Binary files a/public/static/models/Brain.glb and /dev/null differ diff --git a/public/static/models/brain.glb b/public/static/models/brain.glb new file mode 100644 index 00000000..e26591ef Binary files /dev/null and b/public/static/models/brain.glb differ diff --git a/src/App.tsx b/src/App.tsx index 2bc1a813..f1c4b806 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,7 @@ // keycloak -import { AuthContextWrapperProvider } from "./contexts/AuthContextWrapper"; -import { keycloak } from "./keycloak"; +import { oidcConfig } from "./keycloak"; +import { AuthProvider } from "react-oidc-context"; + // routes import Router from "./Router"; // theme @@ -15,10 +16,11 @@ import Iconify from "./components/Iconify"; import { ThemeModeContextProvider } from "./contexts/ThemeModeContext"; import { AlertContextProvider } from "./contexts/AlertContext"; import { AdminResourceContextProvider } from "./contexts/AdminResourceContext"; +import TokenExpiryModal from "./components/TokenExpiryModal"; export default function App() { return ( - + @@ -41,6 +43,7 @@ export default function App() { + @@ -48,6 +51,6 @@ export default function App() { - + ); } diff --git a/src/Router.tsx b/src/Router.tsx index 9abc0097..8de22aa2 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -18,6 +18,7 @@ import Onboarding from "./pages/onboarding"; import Inbox from "./pages/inbox/Inbox"; import Teams from "./pages/teams/Teams"; import { GPU } from "./pages/gpu/GPU"; +import Callback from "./components/Callback"; export default function Router() { return useRoutes([ @@ -28,6 +29,7 @@ export default function Router() { { path: "/", element: }, { path: "/status", element: }, { path: "tiers", element: }, + { path: "oauth2/callback", element: }, { path: "deploy", element: ( diff --git a/src/api/deploy/deployments.ts b/src/api/deploy/deployments.ts index 610aa929..6ff567b2 100644 --- a/src/api/deploy/deployments.ts +++ b/src/api/deploy/deployments.ts @@ -1,4 +1,4 @@ -import { Job } from "../../types"; +import { DeploymentSpecsGPU, Job, Visibility } from "../../types"; export const getDeployment = async (token: string, id: string) => { const url = `${import.meta.env.VITE_DEPLOY_API_URL}/deployments/${id}`; @@ -90,7 +90,9 @@ export const createDeployment = async ( imageArgs: any, envs: any, volumes: any, - token: string + token: string, + visibility: Visibility, + specs?: DeploymentSpecsGPU ) => { let body: any = { name, @@ -99,8 +101,14 @@ export const createDeployment = async ( if (zone) body = { ...body, zone }; if (image) body = { ...body, image }; if (imageArgs) body = { ...body, args: imageArgs }; + if (visibility) body = { ...body, visibility }; if (envs) body = { ...body, envs }; if (volumes) body = { ...body, volumes }; + if (specs?.cpuCores) body = { ...body, cpuCores: specs.cpuCores }; + if (specs?.ram) body = { ...body, ram: specs.ram }; + if (specs?.replicas != undefined) + body = { ...body, replicas: specs.replicas }; + if (specs?.gpus) body = { ...body, gpus: specs.gpus }; const res = await fetch( import.meta.env.VITE_DEPLOY_API_URL + "/deployments", diff --git a/src/api/deploy/gpuClaims.ts b/src/api/deploy/gpuClaims.ts new file mode 100644 index 00000000..e0c4c33d --- /dev/null +++ b/src/api/deploy/gpuClaims.ts @@ -0,0 +1,73 @@ +import { GpuClaimCreate } from "../../temporaryTypesRemoveMe"; +import { Jwt } from "../../types"; + +export const listGpuClaims = async (token: Jwt, detailed?: boolean) => { + const detailedQuery = detailed + ? `?detailed=${encodeURIComponent(detailed)}` + : ""; + const url = `${import.meta.env.VITE_DEPLOY_API_URL}/gpuClaims${detailedQuery}`; + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const result = await response.json(); + + if (!Array.isArray(result)) { + throw new Error("Error listing GPU claims, response was not an array"); + } + + result.sort((a: any, b: any) => { + return a.id < b.id ? -1 : 1; + }); + + return result; +}; + +export const getGpuClaim = async (token: Jwt, gpuClaimId: string) => { + const url = `${import.meta.env.VITE_DEPLOY_API_URL}/gpuClaims/${gpuClaimId}`; + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const result = await response.json(); + if (typeof result !== "object") { + throw new Error("Error getting GPU claim, response was not an object"); + } + return result; +}; + +export const deleteGpuClaim = async (token: Jwt, gpuClaimId: string) => { + const url = `${import.meta.env.VITE_DEPLOY_API_URL}/gpuClaims/${gpuClaimId}`; + const response = await fetch(url, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + const result = await response.json(); + if (typeof result !== "object") { + throw new Error("Error deleting GPU claim, response was not an object"); + } + return result; +}; + +export const createGpuClaim = async (token: Jwt, gpuClaim: GpuClaimCreate) => { + const url = `${import.meta.env.VITE_DEPLOY_API_URL}/gpuClaims`; + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(gpuClaim), + }); + const result = await response.json(); + if (typeof result !== "object") { + throw new Error("Error creating GPU claim, response was not an object"); + } + return result; +}; diff --git a/src/components/Callback.tsx b/src/components/Callback.tsx new file mode 100644 index 00000000..22eb3359 --- /dev/null +++ b/src/components/Callback.tsx @@ -0,0 +1,66 @@ +import { useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "react-oidc-context"; + +import { Box, Button, Paper, Typography } from "@mui/material"; +import Page from "./Page"; +import LoadingPage from "./LoadingPage"; + +export default function Callback() { + const navigate = useNavigate(); + const auth = useAuth(); + + useEffect(() => { + if (!auth.isLoading) { + if (auth.isAuthenticated) { + // Clean up the URL + const url = new URL(window.location.href); + url.searchParams.delete("code"); + url.searchParams.delete("state"); + window.history.replaceState({}, document.title, url.pathname); + + // Redirect to /deploy + navigate("/deploy", { replace: true }); + } + } + }, [auth.isLoading, auth.isAuthenticated, navigate]); + + if (auth.isLoading) { + return ; + } + + if (auth.error) { + return ( + + + + + Authentication Failed + + + {auth.error?.message || "Unknown error occurred during login."} + + + + + + ); + } + + return null; +} diff --git a/src/components/LoadingPage.tsx b/src/components/LoadingPage.tsx index 9770bc8c..cf1e34ff 100644 --- a/src/components/LoadingPage.tsx +++ b/src/components/LoadingPage.tsx @@ -58,16 +58,16 @@ const LoadingPage = () => { direction="column" alignItems="center" justifyContent="center" - style={{ minHeight: "100vh" }} + sx={{ minHeight: "100vh", textAlign: "center" }} > - + - +
{getLoadingMessage()}
{connectionError && retryIn > 0 && ( - +
{`${t("retrying-in")} ${retryIn} ${t("seconds")}`}
)} diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx index 1bba432e..bf76604e 100644 --- a/src/components/Logo.tsx +++ b/src/components/Logo.tsx @@ -1,6 +1,6 @@ import { Link as RouterLink } from "react-router-dom"; import { Box } from "@mui/material"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../hooks/useKeycloak"; import { useContext } from "react"; import { ThemeModeContext } from "../contexts/ThemeModeContext"; diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index 8090abcd..8bb5dfaf 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -1,11 +1,11 @@ import { Navigate } from "react-router-dom"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../hooks/useKeycloak"; import LoadingPage from "./LoadingPage"; -import { useContext } from "react"; -import { AuthContextWrapper } from "../contexts/AuthContextWrapper"; +import { useAuth } from "react-oidc-context"; +//import { AuthContextWrapper } from "../contexts/AuthContextWrapper"; const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { - const { error } = useContext(AuthContextWrapper); + const { error } = useAuth(); const { keycloak, initialized } = useKeycloak(); const renderPage = (children: React.ReactNode) => { diff --git a/src/components/TokenExpiryModal.tsx b/src/components/TokenExpiryModal.tsx new file mode 100644 index 00000000..9cfa6f49 --- /dev/null +++ b/src/components/TokenExpiryModal.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { useAuth } from "react-oidc-context"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, +} from "@mui/material"; + +export default function TokenExpiryModal() { + const auth = useAuth(); + const [open, setOpen] = React.useState(false); + + React.useEffect(() => { + return auth.events.addAccessTokenExpiring(() => { + setOpen(true); + }); + }, [auth.events]); + + const handleContinue = async () => { + try { + await auth.signinSilent(); + setOpen(false); + } catch (err) { + // notistack insted + console.error("Silent renew failed", err); + } + }; + + const handleLogout = () => { + auth.removeUser(); + }; + + return ( + + Session Expiring + + + + Your session is about to expire due to inactivity. Would you like to + stay signed in? + + + + + + + + + + ); +} diff --git a/src/components/admin/CELExprBuilder.tsx b/src/components/admin/CELExprBuilder.tsx new file mode 100644 index 00000000..c968a32f --- /dev/null +++ b/src/components/admin/CELExprBuilder.tsx @@ -0,0 +1,89 @@ +import { Stack, TextField, IconButton, Chip, Typography } from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import { useState } from "react"; + +// Simple CEL validation function (stub, TODO: replace with real parser) +const validateCel = (expr: string): boolean => { + try { + if (!expr) return true; // empty allowed + // TODO: Replace with actual CEL parser/validation + // For now: basic check for balanced parentheses + let count = 0; + for (const c of expr) { + if (c === "(") count++; + if (c === ")") count--; + if (count < 0) return false; + } + return count === 0; + } catch { + return false; + } +}; + +interface CelExprBuilderProps { + value: string[]; + onChange: (value: string[]) => void; + label?: string; + placeholder?: string; +} + +export default function CelExprBuilder({ + value, + onChange, + label = "CEL Expressions", + placeholder = "Enter CEL expression", +}: CelExprBuilderProps) { + const [input, setInput] = useState(""); + + const addExpr = () => { + const expr = input.trim(); + if (!expr) return; + + const newValue = [...value, expr]; + onChange(newValue); + setInput(""); + }; + + const removeExpr = (index: number) => { + onChange(value.filter((_, i) => i !== index)); + }; + + return ( + + {label} + + + {value.map((expr, idx) => { + const valid = validateCel(expr); + return ( + removeExpr(idx)} + /> + ); + })} + + + + setInput(e.target.value)} + error={(input && !validateCel(input)) || false} + helperText={input && !validateCel(input) ? "Invalid CEL syntax" : ""} + /> + + + + + + ); +} diff --git a/src/components/admin/ClusterOverviewTab.tsx b/src/components/admin/ClusterOverviewTab.tsx new file mode 100644 index 00000000..91ce59d2 --- /dev/null +++ b/src/components/admin/ClusterOverviewTab.tsx @@ -0,0 +1,105 @@ +import { + Paper, + Stack, + Typography, + Card, + CardContent, + Chip, + Divider, +} from "@mui/material"; +import useResource from "../../hooks/useResource"; +import { useTranslation } from "react-i18next"; +import DRAConfigPanel from "./DRAConfigPanel"; +import { useEffect, useState } from "react"; +import { discover } from "../../api/deploy/discover"; + +export default function ClusterOverviewTab() { + const { t } = useTranslation(); + const { zones } = useResource(); + const [roles, setRoles] = useState([]); + + useEffect(() => { + discover().then((resp) => { + setRoles([...new Set([...resp.roles.map((r) => r.name), "admin"])]); + }); + }, []); + + return ( + + + {t("clusters-overview")} + + {zones?.map((zone) => { + const hasDRA = zone.capabilities?.includes("dra"); + + return ( + + + + {/* Header */} + + + {zone.name} + + + {zone.description} + + + + {/* Capabilities */} + + {zone.capabilities.map((cap) => ( + + ))} + + + {hasDRA && zone.enabled !== false && ( + <> + + + + )} + + + + ); + })} + + + ); +} diff --git a/src/components/admin/DRAConfigPanel.tsx b/src/components/admin/DRAConfigPanel.tsx new file mode 100644 index 00000000..adaa6481 --- /dev/null +++ b/src/components/admin/DRAConfigPanel.tsx @@ -0,0 +1,109 @@ +import { useState } from "react"; +import { Stack, Typography, Button, Alert, Box } from "@mui/material"; +import GpuClaimModal from "./GPUClaimModal"; +import { GpuClaimCreate } from "../../temporaryTypesRemoveMe"; +import { createGpuClaim } from "../../api/deploy/gpuClaims"; +import { useKeycloak } from "../../hooks/useKeycloak"; +import { enqueueSnackbar } from "notistack"; +import useAdmin from "../../hooks/useAdmin"; + +interface DRAConfigPanelProps { + zone: { + name: string; + }; + roles: string[]; +} + +export default function DRAConfigPanel({ zone, roles }: DRAConfigPanelProps) { + const [gpuModalOpen, setGpuModalOpen] = useState(false); + const { gpuClaims } = useAdmin(); + const { keycloak } = useKeycloak(); + + const handleAddGpuClaim = async (claim: GpuClaimCreate) => { + try { + const response = await createGpuClaim(keycloak.token!, claim); + if (response["validationErrors"] != undefined) { + throw response["validationErrors"]; + } + setGpuModalOpen(false); + } catch (ex) { + enqueueSnackbar<"error">({ message: "Failed to create gpu claim" + ex }); + } + }; + + return ( + + + Dynamic Resource Allocation (DRA) + + + + This zone supports Dynamic Resource Allocation. Configure DRA resources + and policies below. + + + + + {gpuClaims?.length === 0 ? ( + + No DRA resources configured yet. + + ) : ( + + {gpuClaims?.map((claim) => ( + + {claim.name} + + Zone: {claim.zone} + + + Allowed roles: {claim.allowedRoles?.join(", ") || "All"} + + + Requested GPUs:{" "} + {Object.keys(claim.requested || {}).join(", ")} + + + ))} + + )} + + + + + + + + {/* GPU Claim Modal */} + setGpuModalOpen(false)} + onSubmit={handleAddGpuClaim} + /> + + ); +} diff --git a/src/components/admin/GPUClaimEditor.tsx b/src/components/admin/GPUClaimEditor.tsx new file mode 100644 index 00000000..a58ab053 --- /dev/null +++ b/src/components/admin/GPUClaimEditor.tsx @@ -0,0 +1,270 @@ +import { + Stack, + TextField, + Button, + MenuItem, + IconButton, + Paper, + Autocomplete, + Typography, + Chip, +} from "@mui/material"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { + GenericDeviceConfiguration, + RequestedGpuCreate, +} from "../../temporaryTypesRemoveMe"; +import CelExprBuilder from "./CELExprBuilder"; + +interface Props { + value: RequestedGpuCreate[]; + onChange: (value: RequestedGpuCreate[]) => void; +} + +export default function GpuClaimEditor({ value, onChange }: Props) { + const update = (idx: number, patch: Partial) => { + const next = [...value]; + next[idx] = { ...next[idx], ...patch }; + onChange(next); + }; + + const updateRequested = (idx: number, patch: Partial) => { + const next = [...value]; + next[idx] = { + ...next[idx], + ...patch, + }; + onChange(next); + }; + + return ( + + {value.map((req, idx) => { + const gpu = req; + + return ( + + + {/* Header */} + + update(idx, { name: e.target.value })} + required + fullWidth + /> + + onChange(value.filter((_, i) => i !== idx))} + > + + + + + + updateRequested(idx, { + allocationMode: e.target.value, + }) + } + > + All + Exact count + + + {gpu.allocationMode === "ExactCount" && ( + + updateRequested(idx, { + count: Number(e.target.value), + }) + } + /> + )} + + + updateRequested(idx, { + deviceClassName: e.target.value, + }) + } + placeholder="nvidia.com/gpu" + defaultValue={"nvidia.com/gpu"} + helperText="RFC1123 name" + required + /> + + { + updateRequested(idx, { + selectors: exprs, + }); + }} + label="Selectors (CEL expression)" + placeholder="memory <= 16 && cudaComputeCapability >= 7.0" + /> + + + {"Driver configuration"} + + updateRequested(idx, { + config: { driver: e.target.value }, + }) + } + /> + {(gpu.config as GenericDeviceConfiguration)?.driver == + "gpu.nvidia.com" && ( + <> + + updateRequested(idx, { + config: { + driver: + (gpu.config as GenericDeviceConfiguration) + ?.driver || "gpu.nvidia.com", + parameters: { + ...(gpu.config as any)?.parameters, + sharing: { + ...(gpu.config as any)?.parameters?.sharing, + strategy: strat, + }, + }, + }, + }) + } + renderTags={(s, getTagProps) => + s.map((strategy, index) => ( + + )) + } + renderInput={(params) => ( + + )} + /> + + <> + {(gpu.config as any)?.parameters?.sharing?.strategy == + "MPS" ? ( + + + MPS Configuration + + + + updateRequested(idx, { + config: { + driver: "gpu.nvidia.com", + parameters: { + ...(gpu.config as any)?.parameters, + sharing: { + ...(gpu.config as any)?.parameters + ?.sharing, + mps: { + ...(gpu.config as any)?.parameters + ?.sharing?.mps, + defaultActiveThreadPercentage: Number( + e.target.value + ), + }, + }, + }, + }, + }) + } + /> + + + updateRequested(idx, { + config: { + driver: "gpu.nvidia.com", + parameters: { + ...(gpu.config as any)?.parameters, + sharing: { + ...(gpu.config as any)?.parameters + ?.sharing, + mps: { + ...(gpu.config as any)?.parameters + ?.sharing?.mps, + defaultPinnedDeviceMemoryLimit: + e.target.value, + }, + }, + }, + }, + }) + } + /> + + ) : ( + (gpu.config as any)?.parameters?.sharing?.strategy == + "TimeSlicing" && ( + timeslicing TBD + ) + )} + + + )} + + + + ); + })} + + + + ); +} diff --git a/src/components/admin/GPUClaimModal.tsx b/src/components/admin/GPUClaimModal.tsx new file mode 100644 index 00000000..302fc91f --- /dev/null +++ b/src/components/admin/GPUClaimModal.tsx @@ -0,0 +1,203 @@ +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Stack, + TextField, + Button, + Chip, + Autocomplete, + Tooltip, + Typography, + useTheme, +} from "@mui/material"; +import { useState } from "react"; +import GpuClaimEditor from "./GPUClaimEditor"; +import { GpuClaimCreate } from "../../temporaryTypesRemoveMe"; +import Iconify from "../Iconify"; +import { useTranslation } from "react-i18next"; + +interface Props { + open: boolean; + zone: string; + roles: string[]; + initialValue?: GpuClaimCreate; + onClose: () => void; + onSubmit: (value: GpuClaimCreate) => void; +} + +type Role = + | "default" + | "bronze" + | "silver" + | "gold" + | "platinum" + | "admin" + | string; + +export function getChipColor(role: Role) { + const metalStyles: Record = { + bronze: { + background: + "linear-gradient(145deg, #cd7f32 0%, #b06a2f 50%, #d99c6c 100%)", + color: "#000", + boxShadow: "inset 0 1px 2px rgba(255,255,255,0.3)", + }, + silver: { + background: + "linear-gradient(145deg, #e6e8eb 0%, #c0c0c0 50%, #f5f5f5 100%)", + color: "#000", + boxShadow: "inset 0 1px 2px rgba(255,255,255,0.5)", + }, + gold: { + background: + "linear-gradient(145deg, #ffd700 0%, #e6c200 50%, #ffea70 100%)", + color: "#000", + boxShadow: "inset 0 1px 2px rgba(255,255,255,0.4)", + }, + platinum: { + background: + "linear-gradient(145deg, #e5e4e2 0%, #cfcfcf 50%, #ffffff 100%)", + color: "#000", + boxShadow: "inset 0 1px 2px rgba(255,255,255,0.5)", + }, + }; + + const muiColors: Record< + string, + | "default" + | "error" + | "primary" + | "secondary" + | "info" + | "success" + | "warning" + > = { + default: "default", + admin: "error", + }; + + if (metalStyles[role]) { + return { + sx: { + background: metalStyles[role].background, + color: metalStyles[role].color, + boxShadow: metalStyles[role].boxShadow, + }, + }; + } + + return { color: muiColors[role] ?? "default" }; +} + +export default function GpuClaimModal({ + open, + zone, + roles, + initialValue, + onClose, + onSubmit, +}: Props) { + const [value, setValue] = useState( + initialValue ?? { + name: "", + zone, + allowedRoles: [], + requested: [], + } + ); + const { t } = useTranslation(); + const theme = useTheme(); + + return ( + + + {initialValue ? t("edit-gpu-claim") : t("create-gpu-claim")} + + + + + setValue({ ...value, name: e.target.value })} + required + /> + + + + + setValue({ + ...value, + allowedRoles: Array.isArray(roles) ? roles : [roles], + }) + } + renderTags={(r, getTagProps) => + r.map((role, index) => ( + + )) + } + renderInput={(params) => ( + + + + {"These roles will be allowed to use the GPUClaim."} + +

+ + {"If left empty, anyone will be allowed to use it."} + +

+ + { + 'The admin "role" will make sure that only users that are admin can use it.' + } + + + } + > + + + +
+ +
+ )} + /> + + setValue({ ...value, requested })} + /> +
+
+ + + + + +
+ ); +} diff --git a/src/components/admin/HostsTab.tsx b/src/components/admin/HostsTab.tsx index d90d3f3a..0b316c67 100644 --- a/src/components/admin/HostsTab.tsx +++ b/src/components/admin/HostsTab.tsx @@ -53,7 +53,7 @@ export default function HostsTab() { {hosts === undefined ? ( {Array.from({ length: 9 }).map((_, index) => ( - + @@ -73,7 +73,7 @@ export default function HostsTab() { {enabled.map((host) => ( - + {disabled.map((host) => ( - + ))} diff --git a/src/components/admin/TimeAgo.tsx b/src/components/admin/TimeAgo.tsx index 789e47aa..6760e272 100644 --- a/src/components/admin/TimeAgo.tsx +++ b/src/components/admin/TimeAgo.tsx @@ -1,10 +1,12 @@ import React, { useState, useEffect } from "react"; -import { Typography } from "@mui/material"; +import { Typography, TypographyVariant } from "@mui/material"; -const TimeAgo: React.FC<{ createdAt: string | undefined }> = ({ - createdAt, -}) => { +const TimeAgo: React.FC<{ + createdAt: string | undefined; + variant?: TypographyVariant | undefined; +}> = ({ createdAt, variant }) => { const [timeAgo, setTimeAgo] = useState(""); + variant = variant != undefined ? variant : "body2"; const calculateTimeAgo = (createdAt: string) => { const now = new Date().getTime(); @@ -44,7 +46,7 @@ const TimeAgo: React.FC<{ createdAt: string | undefined }> = ({ } }, [createdAt]); - return {timeAgo}; + return {timeAgo}; }; export default TimeAgo; diff --git a/src/components/chart/BaseOptionChart.tsx b/src/components/chart/BaseOptionChart.tsx index 68e17d43..55c2ed69 100644 --- a/src/components/chart/BaseOptionChart.tsx +++ b/src/components/chart/BaseOptionChart.tsx @@ -172,6 +172,7 @@ export default function BaseOptionChart(): ApexOptions { position: "top", horizontalAlign: "right", markers: { + //@ts-ignore idk if this exists or not. radius: 12, }, fontWeight: 500, diff --git a/src/components/create/EnvironmentVariableSelector.tsx b/src/components/create/EnvironmentVariableSelector.tsx new file mode 100644 index 00000000..4872576e --- /dev/null +++ b/src/components/create/EnvironmentVariableSelector.tsx @@ -0,0 +1,155 @@ +import { + Card, + CardContent, + CardHeader, + IconButton, + Paper, + Stack, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, +} from "@mui/material"; +import { NoWrapTable as Table } from "../../components/NoWrapTable"; +import Iconify from "../Iconify"; +import { Dispatch, SetStateAction } from "react"; +import { EnvVar } from "../../types"; +import { useTranslation } from "react-i18next"; + +export type EnvironmentVariableSelectorProps = { + envs: EnvVar[]; + setEnvs: Dispatch>; + currentEnv: EnvVar; + setCurrentEnv: Dispatch>; +}; + +export default function EnvironmentVariableSelector({ + envs, + setEnvs, + currentEnv, + setCurrentEnv, +}: EnvironmentVariableSelectorProps) { + const { t } = useTranslation(); + + return ( + + + + + + + + {t("create-deployment-env-key")} + {t("create-deployment-env-value")} + {t("admin-actions")} + + + + {envs.map((env) => ( + + + {env.name} + + + {env.value} + + + + { + setCurrentEnv({ name: env.name, value: env.value }); + setEnvs( + envs.filter((item) => item.name !== env.name) + ); + }} + > + + + + + setEnvs(envs.filter((item) => item.name !== env.name)) + } + > + + + + + + ))} + + + + { + setCurrentEnv({ ...currentEnv, name: e.target.value }); + }} + /> + + + { + setCurrentEnv({ ...currentEnv, value: e.target.value }); + }} + fullWidth + /> + + + { + if (!(currentEnv.name != "" && currentEnv.value != "")) + return; + + setEnvs([...envs, currentEnv]); + + setCurrentEnv({ name: "", value: "" }); + }} + > + + + + + +
+
+
+
+ ); +} diff --git a/src/components/create/GpuSelector.tsx b/src/components/create/GpuSelector.tsx new file mode 100644 index 00000000..7757a484 --- /dev/null +++ b/src/components/create/GpuSelector.tsx @@ -0,0 +1,240 @@ +import { Dispatch, SetStateAction, useState } from "react"; +import { DeploymentGPU } from "../../types"; +import { useTranslation } from "react-i18next"; +import useResource from "../../hooks/useResource"; +import { + Card, + CardContent, + CardHeader, + Typography, + IconButton, + TableBody, + TableRow, + TableHead, + TableCell, + Autocomplete, + TextField, + Stack, + Tooltip, +} from "@mui/material"; +import { NoWrapTable as Table } from "../../components/NoWrapTable"; +import Iconify from "../Iconify"; +import { enqueueSnackbar } from "notistack"; + +export type GPUSelectorProps = { + gpus: DeploymentGPU[]; + setGpus: Dispatch>; + zone: string; +}; + +export default function GPUSelector({ gpus, setGpus, zone }: GPUSelectorProps) { + const { t } = useTranslation(); + const { gpuClaims, zones, user } = useResource(); + + const [selectedOption, setSelectedOption] = useState(null); + const usage = ((user?.usage as any)?.gpus as number) || 0 + gpus.length; + + const selectedZone = zones.find((z) => z.name === zone); + + const draCapableZone = + (selectedZone?.enabled && + selectedZone?.capabilities.some((cap) => cap === "dra")) || + false; + + const availableGpuClaims = gpuClaims?.filter((c) => c.zone === zone) || []; + + const addGpu = (claimName: string, gpuName: string) => { + if (usage + 1 > (((user?.quota as any)?.gpus as number) || 1)) { + enqueueSnackbar(t("quota-exceeded"), { variant: "error" }); + return; + } + setGpus((prev) => { + if (prev.some((g) => g.claimName === claimName && g.name === gpuName)) + return prev; + + return [ + ...prev, + { + name: gpuName, + claimName, + }, + ]; + }); + }; + + const removeGpu = (index: number) => { + setGpus((prev) => prev.filter((_, i) => i !== index)); + }; + + const gpuOptions = availableGpuClaims.flatMap((claim) => { + if (!claim.requested) return []; + + return Object.entries(claim.requested).map(([requestName, req]) => ({ + claimName: claim.name, + requestName, + deviceClass: req.deviceClassName, + vendor: getVendor(req.deviceClassName), + label: `${claim.name} / ${requestName}`, + })); + }); + + return ( + + + + {t("deployment-gpu-subheader")} + +
+
+ + {t("deployment-gpu-quota")} + +
+
+ + {t("deployment-gpu-unstable")} + + + } + > + + + + + } + /> + + + {draCapableZone ? ( + <> + + + + {t("deployment-gpu-claim-name")} + {t("deployment-gpu-name")} + {t("deployment-gpu-vendor")} + {t("deployment-gpu-count")} + {t("admin-actions")} + + + + {gpus.length > 0 ? ( + gpus.map((gpu, index) => { + const claim = availableGpuClaims.find( + (c) => c.name === gpu.claimName + ); + + return ( + + {gpu.claimName} + {gpu.name} + + {getVendor( + claim?.requested?.[gpu.name].deviceClassName + )} + + + {claim?.requested?.[gpu.name].allocationMode === + "ExactCount" + ? claim?.requested?.[gpu.name].count || "1" + : "all"} + + + + removeGpu(index)} + > + + + + + + ); + }) + ) : ( + + + + {t("nothing-to-see-here")} + + + + )} + +
+ + + {t("deployment-gpu-add")} + + + { + if (!value) return; + + addGpu(value.claimName, value.requestName); + setSelectedOption(null); + }} + getOptionLabel={(o) => o.label} + renderInput={(params) => ( + + )} + renderOption={(props, option) => ( +
  • + + +
    + {option.claimName} + + {option.requestName} • {option.vendor} + +
    +
  • + )} + sx={{ mt: 1 }} + /> + + ) : ( + + {t("deployment-gpu-create-select-zone-not-capable")} + + )} +
    +
    + ); +} + +const getVendor = (deviceClassName?: string) => { + if (!deviceClassName) return "unknown"; + + if (deviceClassName.includes("nvidia")) return "NVIDIA"; + if (deviceClassName.includes("amd")) return "AMD"; + if (deviceClassName.includes("intel")) return "Intel"; + + return "GPU"; +}; diff --git a/src/components/create/PersistentVolumeSelector.tsx b/src/components/create/PersistentVolumeSelector.tsx new file mode 100644 index 00000000..c070d8c6 --- /dev/null +++ b/src/components/create/PersistentVolumeSelector.tsx @@ -0,0 +1,222 @@ +import { + Card, + CardContent, + CardHeader, + FormControlLabel, + IconButton, + Paper, + Stack, + Switch, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, +} from "@mui/material"; +import { NoWrapTable as Table } from "../../components/NoWrapTable"; +import Iconify from "../Iconify"; +import { Dispatch, SetStateAction } from "react"; +import { Volume } from "@kthcloud/go-deploy-types/types/v2/body"; +import { useTranslation } from "react-i18next"; + +export type PersistentVolumeSelectorProps = { + usePersistent: boolean; + setUsePersistent: Dispatch>; + volumes: Volume[]; + setVolumes: Dispatch>; + currentVolume: Volume; + setCurrentVolume: Dispatch>; +}; + +export default function PersistentVolumeSelector({ + usePersistent, + setUsePersistent, + volumes, + setVolumes, + currentVolume, + setCurrentVolume, +}: PersistentVolumeSelectorProps) { + const { t } = useTranslation(); + return ( + + + + setUsePersistent(e.target.checked)} + inputProps={{ "aria-label": "controlled" }} + /> + } + label={t("create-deployment-persistent")} + /> + {usePersistent && ( + + + + + {t("admin-name")} + {t("create-deployment-app-path")} + {t("create-deployment-storage-path")} + {t("admin-actions")} + + + + {volumes.map((persistentRecord) => ( + + + + {persistentRecord.name} + + + + + {persistentRecord.appPath} + + + + + {persistentRecord.serverPath} + + + + + { + setCurrentVolume(persistentRecord); + + setVolumes( + volumes.filter( + (item) => item.name !== persistentRecord.name + ) + ); + }} + > + + + + setVolumes( + volumes.filter( + (item) => item.name !== persistentRecord.name + ) + ) + } + > + + + + + + ))} + + + + { + setCurrentVolume({ + ...currentVolume, + name: e.target.value, + }); + }} + /> + + + { + setCurrentVolume({ + ...currentVolume, + appPath: e.target.value, + }); + }} + fullWidth + /> + + + { + setCurrentVolume({ + ...currentVolume, + serverPath: e.target.value, + }); + }} + fullWidth + /> + + + { + if ( + !( + currentVolume.appPath && + currentVolume.serverPath && + currentVolume.name + ) + ) + return; + + setVolumes([...volumes, currentVolume]); + + setCurrentVolume({ + name: "", + appPath: "", + serverPath: "", + }); + }} + > + + + + + +
    +
    + )} +
    +
    + ); +} diff --git a/src/components/create/SpecSelector.tsx b/src/components/create/SpecSelector.tsx new file mode 100644 index 00000000..87825b5f --- /dev/null +++ b/src/components/create/SpecSelector.tsx @@ -0,0 +1,185 @@ +import { Dispatch, SetStateAction, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Card, + CardContent, + CardHeader, + Grid, + TextField, + InputAdornment, +} from "@mui/material"; +import useResource from "../../hooks/useResource"; + +export type SpecSelectorProps = { + cpuCores: number; // per replica + ram: number; // per replica + replicas: number; + + setCpuCores: Dispatch>; + setRam: Dispatch>; + setReplicas: Dispatch>; +}; + +export default function SpecSelector({ + cpuCores, + ram, + replicas, + setCpuCores, + setRam, + setReplicas, +}: SpecSelectorProps) { + const { t } = useTranslation(); + const { user } = useResource(); + + const [cpuInput, setCpuInput] = useState(cpuCores.toString()); + const [ramInput, setRamInput] = useState(ram.toString()); + const [replicasInput, setReplicasInput] = useState( + replicas.toString() + ); + + const [cpuError, setCpuError] = useState(""); + const [ramError, setRamError] = useState(""); + const [replicasError, setReplicasError] = useState(""); + + const maxCpu = user?.quota.cpuCores || 4; + const maxRam = user?.quota.ram || 8; + + const validateQuota = ( + type: "cpu" | "ram" | "replicas", + value: number, + total?: number + ): string => { + switch (type) { + case "cpu": + if (total! + (user?.usage.cpuCores || 0) > maxCpu) { + return `Total CPU (${total}) exceeds quota (${maxCpu})`; + } + break; + + case "ram": + if (total! + (user?.usage.ram || 0) > maxRam) { + return `Total RAM (${total}) exceeds quota (${maxRam} GB)`; + } + break; + + case "replicas": + if (value < 0) return "Replicas cannot be negative"; + break; + } + return ""; + }; + + const handleCpuBlur = () => { + const value = parseFloat(cpuInput); + if (isNaN(value) || value < 0.1) { + setCpuError("CPU must be ≥ 0.1"); + return; + } + const error = validateQuota("cpu", value, value * replicas); + setCpuError(error); + if (!error) setCpuCores(value); + }; + + const handleRamBlur = () => { + const value = parseFloat(ramInput); + if (isNaN(value) || value < 0.1) { + setRamError("RAM must be ≥ 0.1"); + return; + } + const error = validateQuota("ram", value, value * replicas); + setRamError(error); + if (!error) setRam(value); + }; + + const handleReplicasBlur = () => { + const value = parseInt(replicasInput); + if (isNaN(value) || value < 0) { + setReplicasError("Replicas must be ≥ 0"); + return; + } + const coresInput = parseFloat(cpuInput); + if (isNaN(coresInput) || coresInput < 0.1) { + setCpuError("CPU must be ≥ 0.1"); + return; + } + const ramUInput = parseFloat(ramInput); + if (isNaN(ramUInput) || ramUInput < 0.1) { + setRamError("RAM must be ≥ 0.1"); + return; + } + + const cpuTotal = coresInput * value; + const ramTotal = ramUInput * value; + + const cpuErr = validateQuota("cpu", coresInput, cpuTotal); + const ramErr = validateQuota("ram", ramUInput, ramTotal); + const repErr = validateQuota("replicas", value); + + setCpuError(cpuErr); + setRamError(ramErr); + setReplicasError(repErr); + + if (!cpuErr && !ramErr && !repErr) setReplicas(value); + }; + + return ( + + + + + + setCpuInput(e.target.value)} + onBlur={handleCpuBlur} + error={!!cpuError} + helperText={cpuError} + inputProps={{ step: 0.1, min: 0.1 }} + fullWidth + InputProps={{ + endAdornment: ( + cores + ), + }} + /> + + + + setRamInput(e.target.value)} + onBlur={handleRamBlur} + error={!!ramError} + helperText={ramError} + inputProps={{ step: 0.1, min: 0.1 }} + fullWidth + InputProps={{ + endAdornment: ( + GB + ), + }} + /> + + + + setReplicasInput(e.target.value)} + onBlur={handleReplicasBlur} + error={!!replicasError} + helperText={replicasError} + inputProps={{ step: 1, min: 0 }} + fullWidth + /> + + + + + ); +} diff --git a/src/components/create/VisibilitySelector.tsx b/src/components/create/VisibilitySelector.tsx new file mode 100644 index 00000000..07cbf3be --- /dev/null +++ b/src/components/create/VisibilitySelector.tsx @@ -0,0 +1,63 @@ +import { Dispatch, SetStateAction } from "react"; +import { Visibility } from "../../types"; +import { + Card, + CardContent, + CardHeader, + ToggleButton, + ToggleButtonGroup, + Tooltip, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; + +export type VisibilitySelectorProps = { + visibility: Visibility; + setVisibility: Dispatch>; +}; + +export default function VisibilitySelector({ + visibility, + setVisibility, +}: VisibilitySelectorProps) { + const { t } = useTranslation(); + const handleChange = ( + _: React.MouseEvent, + value: Visibility + ) => { + setVisibility(value); + }; + return ( + + + + + + + + {t("admin-visibility-public")} + + + + + <>{t("admin-visibility-auth")} + + + + + {t("admin-visibility-private")} + + + + + + ); +} diff --git a/src/components/render/Resource.tsx b/src/components/render/Resource.tsx index fd582e5b..a234c355 100644 --- a/src/components/render/Resource.tsx +++ b/src/components/render/Resource.tsx @@ -244,6 +244,18 @@ export const renderStatusCode = (row: Resource) => { ); }; +export const renderDeploymentGPU = (gpus: Deployment["specs"]["gpus"]) => { + if (!gpus || gpus.length <= 0) return null; + return ( + + ); +}; + export const renderZone = (row: Resource, zones: ZoneRead[]) => { if (!row.zone || !zones) { return <>; diff --git a/src/contexts/AdminResourceContext.tsx b/src/contexts/AdminResourceContext.tsx index 9062d55c..3292ac33 100644 --- a/src/contexts/AdminResourceContext.tsx +++ b/src/contexts/AdminResourceContext.tsx @@ -20,7 +20,7 @@ import { import useFilterableResourceState, { DEFAULT_PAGESIZE, } from "../hooks/useFilterableResourceState"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../hooks/useKeycloak"; import { getUsers } from "../api/deploy/users"; import { errorHandler } from "../utils/errorHandler"; import { enqueueSnackbar } from "notistack"; @@ -35,6 +35,8 @@ import useResource from "../hooks/useResource"; import { TFunction } from "i18next"; import { getHostsVerbose } from "../api/deploy/hosts"; import { getSystemCapacities } from "../api/deploy/systemCapacities"; +import { GpuClaimRead } from "../temporaryTypesRemoveMe"; +import { listGpuClaims } from "../api/deploy/gpuClaims"; type AdminResourceContextType = { fetchingEnabled: boolean; @@ -118,6 +120,9 @@ type AdminResourceContextType = { // SystemCapacities systemCapacities: SystemCapacities | undefined; + + // GpuClaims + gpuClaims: GpuClaimRead[] | undefined; }; const initialState: AdminResourceContextType = { @@ -202,6 +207,9 @@ const initialState: AdminResourceContextType = { // SystemCapacities systemCapacities: undefined, + + // GpuClaims + gpuClaims: undefined, }; export const AdminResourceContext = createContext(initialState); @@ -295,6 +303,10 @@ export const AdminResourceContextProvider = ({ SystemCapacities | undefined >(undefined); + const [gpuClaims, setGpuClaims] = useState( + undefined + ); + const [lastRefreshRtt, setLastRefreshRtt] = useState(0); const [lastRefresh, setLastRefresh] = useState(0); const [loading, setLoading] = useState(false); @@ -336,7 +348,8 @@ export const AdminResourceContextProvider = ({ jobsPageSize, setJobs, setHosts, - setSystemCapacities + setSystemCapacities, + setGpuClaims ).finally(() => setLoading(false)); } }; @@ -458,6 +471,9 @@ export const AdminResourceContextProvider = ({ // SystemCapacities systemCapacities, + + // GpuClaims + gpuClaims, }} > {children} @@ -511,7 +527,10 @@ async function fetchResources( setHosts: Dispatch>, // SystemCapacities - setSystemCapacities: Dispatch> + setSystemCapacities: Dispatch>, + + // GpuClaims + setGpuClaims: Dispatch> ) { if (!(initialized && keycloak.authenticated && keycloak.token)) return; const rtts: Record = {}; @@ -656,6 +675,21 @@ async function fetchResources( ); } }, + + async () => { + try { + const start = performance.now(); + const response = await listGpuClaims(keycloak.token!, true); + rtts[9] = { start, end: performance.now() }; + if (response) setGpuClaims(response); + } catch (error: any) { + errorHandler(error).forEach((e) => + enqueueSnackbar(t("error-could-not-fetch-gpu-claims") + ": " + e, { + variant: "error", + }) + ); + } + }, ]; await Promise.all(promises.map((p) => p())); diff --git a/src/contexts/AuthContextWrapper.tsx b/src/contexts/AuthContextWrapper.tsx deleted file mode 100644 index 74d4d647..00000000 --- a/src/contexts/AuthContextWrapper.tsx +++ /dev/null @@ -1,48 +0,0 @@ -// keycloak -import { ReactKeycloakProvider } from "@react-keycloak/web"; -import Keycloak from "keycloak-js"; - -import { - AuthClientEvent, - AuthClientError, -} from "@react-keycloak/core/lib/types"; -import { createContext, useState } from "react"; - -type AuthContextWrapperType = { - events: AuthClientEvent[]; - error?: AuthClientError; -}; - -const initialState: AuthContextWrapperType = { - events: [], -}; - -export const AuthContextWrapper = createContext(initialState); - -export const AuthContextWrapperProvider = ({ - authClient, - children, -}: { - authClient: Keycloak; - children: React.ReactNode; -}) => { - const [events, setEvents] = useState([]); - const [error, setError] = useState(undefined); - - const handleEvent = ( - event: AuthClientEvent, - error: AuthClientError | undefined - ) => { - setEvents([...events, event]); - if (error) { - setError(error); - } - }; - return ( - - - {children} - - - ); -}; diff --git a/src/contexts/ResourceContext.tsx b/src/contexts/ResourceContext.tsx index 089494e0..2b562f30 100644 --- a/src/contexts/ResourceContext.tsx +++ b/src/contexts/ResourceContext.tsx @@ -2,7 +2,7 @@ import React, { useState, createContext, useEffect } from "react"; import useInterval from "../hooks/useInterval"; import { useSnackbar } from "notistack"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../hooks/useKeycloak"; // api import { getJob } from "../api/deploy/jobs"; @@ -28,6 +28,8 @@ import { import { listGpuGroups } from "../api/deploy/gpuGroups"; import { listGpuLeases } from "../api/deploy/gpuLeases"; import { listMigrations } from "../api/deploy/resourceMigrations"; +import { GpuClaimRead } from "../temporaryTypesRemoveMe"; +import { listGpuClaims } from "../api/deploy/gpuClaims"; type ResourceContextType = { rows: Resource[]; @@ -52,6 +54,7 @@ type ResourceContextType = { setGpuGroups: (gpuGroups: GpuGroupRead[]) => void; gpuLeases: GpuLeaseRead[]; setGpuLeases: (gpuLeases: GpuLeaseRead[]) => void; + gpuClaims: GpuClaimRead[] | undefined; resourceMigrations: ResourceMigrationRead[]; setResourceMigrations: (resourceMigrations: ResourceMigrationRead[]) => void; queueJob: (job: Job) => void; @@ -87,6 +90,7 @@ const initialState: ResourceContextType = { setGpuGroups: () => {}, gpuLeases: new Array(), setGpuLeases: () => {}, + gpuClaims: new Array(), resourceMigrations: new Array(), setResourceMigrations: () => {}, queueJob: () => {}, @@ -124,6 +128,7 @@ export const ResourceContextProvider = ({ const [zones, setZones] = useState([]); const [gpuGroups, setGpuGroups] = useState([]); const [gpuLeases, setGpuLeases] = useState([]); + const [gpuClaims, setGpuClaims] = useState([]); const [resourceMigrations, setResourceMigrations] = useState< ResourceMigrationRead[] >([]); @@ -253,6 +258,20 @@ export const ResourceContextProvider = ({ } }; + const loadGpuClaims = async () => { + if (!(initialized && keycloak.authenticated && keycloak.token)) return; + try { + const gpuClaims = await listGpuClaims(keycloak.token); + setGpuClaims(gpuClaims); + } catch (error: any) { + errorHandler(error).forEach((e) => + enqueueSnackbar("Error fetching GPU claims: " + e, { + variant: "error", + }) + ); + } + }; + const loadResourceMigrations = async () => { if (!(initialized && keycloak.authenticated && keycloak.token)) return; try { @@ -391,6 +410,7 @@ export const ResourceContextProvider = ({ loadZones(); loadGpuGroups(); loadGpuLeases(); + loadGpuClaims(); loadResourceMigrations(); // eslint-disable-next-line @@ -440,6 +460,7 @@ export const ResourceContextProvider = ({ setGpuGroups, gpuLeases, setGpuLeases, + gpuClaims, resourceMigrations, setResourceMigrations, queueJob, diff --git a/src/hooks/useKeycloak.ts b/src/hooks/useKeycloak.ts new file mode 100644 index 00000000..68b0c306 --- /dev/null +++ b/src/hooks/useKeycloak.ts @@ -0,0 +1,28 @@ +import { useAuth } from "react-oidc-context"; + +type KeycloakLike = { + authenticated: boolean; + token?: string; + tokenParsed?: Record; + subject?: string; + logout: () => Promise; + login: () => Promise; +}; + +export const useKeycloak = () => { + const auth = useAuth(); + + const keycloak: KeycloakLike = { + authenticated: auth.isAuthenticated, + token: auth.user?.access_token, + tokenParsed: auth.user?.profile, + subject: auth.user?.profile?.sub, + login: () => auth.signinRedirect(), + logout: () => auth.removeUser(), + }; + + return { + initialized: !auth.isLoading, + keycloak, + }; +}; diff --git a/src/keycloak.ts b/src/keycloak.ts index 3b88376c..3fb146aa 100644 --- a/src/keycloak.ts +++ b/src/keycloak.ts @@ -1,11 +1,12 @@ -import Keycloak from "keycloak-js"; - -const config = { - url: import.meta.env.VITE_KEYCLOAK_URL, - realm: import.meta.env.VITE_KEYCLOAK_REALM, - clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID, +const oidcConfig = { + authority: + import.meta.env.VITE_KEYCLOAK_URL + + "/realms/" + + import.meta.env.VITE_KEYCLOAK_REALM, + client_id: import.meta.env.VITE_KEYCLOAK_CLIENT_ID, + redirect_uri: window.location.origin + "/oauth2/callback", + response_type: "code", + scope: "openid profile email", }; -const keycloak = new Keycloak(config); - -export { keycloak }; +export { oidcConfig }; diff --git a/src/layouts/dashboard/LoginButton.tsx b/src/layouts/dashboard/LoginButton.tsx index 5dd97ea0..8840cc6d 100644 --- a/src/layouts/dashboard/LoginButton.tsx +++ b/src/layouts/dashboard/LoginButton.tsx @@ -1,6 +1,6 @@ import { IconButton, Tooltip } from "@mui/material"; import Iconify from "../../components/Iconify"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../hooks/useKeycloak"; import { useTranslation } from "react-i18next"; export default function LoginButton() { @@ -23,11 +23,7 @@ export default function LoginButton() { ) : ( - keycloak.login({ - redirectUri: window.location.origin + "/deploy", - }) - } + onClick={() => keycloak.login()} sx={{ width: 40, height: 40 }} > diff --git a/src/layouts/dashboard/Shortcuts.tsx b/src/layouts/dashboard/Shortcuts.tsx index ec768eaf..c4a695ce 100644 --- a/src/layouts/dashboard/Shortcuts.tsx +++ b/src/layouts/dashboard/Shortcuts.tsx @@ -1,6 +1,6 @@ import { IconButton, Tooltip } from "@mui/material"; import Iconify from "../../components/Iconify"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../hooks/useKeycloak"; import { Link } from "react-router-dom"; import HelpButton from "./HelpButton"; import LocaleSwitcher from "./LocaleSwitcher"; diff --git a/src/locales/en.json b/src/locales/en.json index f53eabd4..7557ba68 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -137,6 +137,7 @@ "resouce-comparison-stateless-frontend": "Stateless frontend service: eg. React, AngularJS", "resouce-comparison-stateless-backend": "Stateless backend service: eg. Express.js, Flask", "resouce-comparison-machine-learning-gpu": "Machine learning GPU compute: eg. TensorFlow, JupyterLab", + "resouce-comparison-llm": "Run Large Language Models: eg. Ollama, llama.cpp", "resouce-comparison-game-streaming-server": "Game streaming server: eg. Steam Remote Play, Parsec", "resouce-comparison-databases": "Databases: eg. MySQL, MongoDB", "resouce-comparison-real-time-analytics": "Real-time analytics: eg. Apache Kafka, Elasticsearch", @@ -155,8 +156,8 @@ "llama-mysql": "mysql database", "llama-machine-learning": "machine learning", "llama-react-frontend": "react frontend", - "explain-deployment": "Ideal for web apps. Provides the ability to easily run a single container. Allows for CI/CD through GitHub Actions and other pipelines. Your repo must have a Dockerfile.", - "explain-vm": "Provides the ability to run an operating system directly. More versatile but deployment and maintenance will be more difficult. Ideal for GPU compute.", + "explain-deployment": "Ideal for web apps. Provides the ability to easily run a single or muliple containers. Allows for CI/CD through GitHub Actions and other pipelines.", + "explain-vm": "Provides the ability to run an operating system directly. More versatile but deployment and maintenance will be more difficult.", "choose-zone": "Choose zone", "zone": "Zone", "choose-zone-subheader-1": "Your chosen zone determines where your", @@ -271,6 +272,7 @@ "network-speed": "Network speed", "ssh-string": "SSH Connection string", "ssh-string-subheader": "Run this in your terminal to connect to your VM", + "ssh-string-subheader-deployment": "Run this in your terminal to connect to one of the containers in your deployment", "vm-deleting": "VM deleting...", "error-deleting-vm": "Error deleting VM: ", "vm-in-progress": " VM in progress...", @@ -296,6 +298,7 @@ "onboarding-resources-3": "If you need more resources, please contact us on Discord!", "onboarding-deployments-1": "Deployments are the most common resource type on kthcloud. They are perfect for running experiments, or hosting websites.", "onboarding-deployments-2": "All you need is a Docker image or a repo with a Dockerfile, and a little bit of configuration. kthcloud will take care of the rest.", + "onboarding-deployments-3": "You can add GPU compute to your deployment to use for machine learning.", "onboarding-vms-1": "Virtual machines are the most flexible resource type on kthcloud. You can install any programs you want and have full control over the machine.", "onboarding-vms-2": "Virtual machines are perfect for running machine learning models, databases and other more complex applications.", "onboarding-vms-3": "You can also request a GPU for your virtual machine. Please note that GPU resources are limited, for extended use you may want to provide your own GPU.", @@ -312,7 +315,7 @@ "successfully-updated": "Successfully updated", "gravatar": "Change profile picture on Gravatar", "ssh-public-keys": "SSH public keys", - "ssh-public-keys-subheader-1": "Your public keys will be installed when creating a VM. Changes will not apply to existing resources", + "ssh-public-keys-subheader-1": "Your public keys will be installed when creating a VM. Changes will not apply to existing VMs. Your deployments are also accessible through any of these public keys.", "ssh-public-keys-subheader-2": "Please ensure you are uploading your public SSH key", "e-g": "e.g.", "key": "Key", @@ -559,6 +562,29 @@ "maia-intro-body": "Are you a researcher or student in biomedical engineering at KTH who needs state-of-the-art deep learning resources and compute? MAIA is the platform for developing, testing, and deploying medical AI, from early prototypes to real clinical workflows. You get straightforward access to compute via JupyterHub, SSH, or a virtual desktop. Our infrastructure, provided jointly with KTH Cloud, scales to different computational needs. Designed for collaboration and integration with hospital systems, MAIA lets you validate ideas and pilot your solutions in real-world clinical settings.", "maia-intro-footer": "Request a MAIA account today and register a project to start building your medical AI solution.", "maia-intro-header": "Discover ", - "button-get-started-maia": "Get started with MAIA" + "button-get-started-maia": "Get started with MAIA", + "use-vms": "Use VMs", + "landing-hero-gpus": "GPUs (deployment)", + "deployment-gpu": "GPU", + "deployment-gpu-claim-name": "Claim", + "deployment-gpu-name": "GPU", + "deployment-gpu-vendor": "Vendor", + "deployment-gpu-count": "Count", + "deployment-gpu-configuration": "Configure GPUs for deployment", + "deployment-gpu-none": "No GPUs", + "deployment-gpu-add": "Add GPUs", + "deployment-gpu-subheader": "GPUs on deployments utilizes a new k8s feature called DRA combined with driver specific features to share GPUs between multiple containers.", + "deployment-gpu-quota": "GPU quota is user global, if your quota allows multiple GPUs you can allocate them how you like, multiple on a single deployment or single allocation for different deployments.", + "deployment-gpu-unstable": "This feature relies on BETA features from NVIDIA, and issues might occur. Feel free to reach out on discord if you have any questions.", + "deployment-gpu-create-select-title": "Add GPU resources", + "clusters-overview": "Clusters", + "active-keys": "Active keys", + "expired-keys": "Expired keys", + "delete-all-expired": "Delete all expired", + "custom-deployment": "Custom", + "custom-deployment-description": "Build and use your own image, use CI/CD through Github actions / Gitlab workflows or build and push your own image. Secrets will be provided after the deployment is created", + "deployment-spec-selector": "Select specs", + "deployment-ssh-info": "SSH into your deployments using a private key linked to a public key in your profile", + "deployment-ssh-view-keys": "View your ssh keys in your profile" } } diff --git a/src/locales/se.json b/src/locales/se.json index c6a7c2f1..15232ca0 100644 --- a/src/locales/se.json +++ b/src/locales/se.json @@ -44,6 +44,7 @@ "button-get-started": "Kom igång", "gpu-lease-subheader": "Genom att låna ett grafikkort får du använda det under en begränsad tidsperiod.", "resouce-comparison-serverless-computing-services": "Serverlösa tjänster: t.ex. Cloudflare-workers", + "resouce-comparison-llm": "Köra LLMs: t.ex. Ollama, llama.cpp", "reset-onboarding": "Visa genomgång", "button-delete": "Radera", "gpu-capacity": "GPU-kapacitet", @@ -163,7 +164,7 @@ "danger-zone": "Farlig zon", "github-actions-quickstart": "GitHub Actions dokumentation", "onboarding-wait-2": "Hmm, det här verkar ta för lång tid. Du kan prova att ladda om sidan eller kontakta oss på Discord.", - "ssh-public-keys-subheader-1": "Dina nycklar kommer att installeras när du skapar en virtuell maskin. Ändringar kommer inte att installeras på befintliga maskiner", + "ssh-public-keys-subheader-1": "Dina nycklar kommer att installeras när du skapar en virtuell maskin. Ändringar kommer inte att installeras på befintliga virtuella maskiner. Dessa nycklar ger även SSH åtkomst till deployments, nya nycklar har åtkomst till äldre deployments", "onboarding-wait-1": "Vänta medan vi förbereder ditt konto.", "ssh-public-keys-subheader-2": "Se till att du laddar upp publika nyckeln", "saving-domain-update": "Sparar uppdatering av domän...", @@ -282,6 +283,7 @@ "onboarding-gpu-2": "GPU-resurserna är begränsade, för längre användning kan du köpa ett eget grafikkort och få det installerat i molnet.", "onboarding-gpu-1": "kthcloud ger tillgång till senaste grafikkorten från NVIDIA. De är perfekta för att träna maskininlärningsmodeller.", "ssh-string-subheader": "Kör det här i din terminal för att komma åt din maskin", + "ssh-string-subheader-deployment": "Kör det här i din terminal för att komma åt en av containrarna i din deployment", "e-g": "till exempel", "revert-to-this-snapshot": "Återgå till denna avbildning", "failed-update": "Kunde inte uppdatera", @@ -560,6 +562,29 @@ "maia-intro-body": "Är du forskare eller student inom medicinsk teknik på KTH och behöver tillgång till datorresurser för deep learning och simuleringar? MAIA är en plattform för att utveckla, testa och implementera medicinsk AI, från tidiga prototyper till kliniska arbetsflöden. Du får åtkomst till beräkningsresurser via JupyterHub, SSH eller en virtual desktop. Vår infrastruktur, som tillhandahålls tillsammans med KTH Cloud, kan möta olika typer av behov för beräkningsresurser. MAIA är utformad för samarbete och integration med sjukhussystem och gör det möjligt för dig att validera idéer och testa dina lösningar även i kliniska miljöer.", "maia-intro-footer": "Skapa ett MAIA-konto redan idag och registrera ett projekt", "maia-intro-header": "Testa ", - "button-get-started-maia": "Kom igång med MAIA" + "button-get-started-maia": "Kom igång med MAIA", + "use-vms": "Använda VMar", + "landing-hero-gpus": "GPUs (deployment)", + "deployment-gpu": "GPU", + "deployment-gpu-claim-name": "Claim", + "deployment-gpu-name": "GPU", + "deployment-gpu-vendor": "Tillverkare", + "deployment-gpu-count": "Antal", + "deployment-gpu-configuration": "Konfigurera GPUer för deployment", + "deployment-gpu-none": "Inga GPUer", + "deployment-gpu-add": "Lägg till GPUer", + "deployment-gpu-subheader": "GPUs på deployments använder en ny k8s funktion som heter DRA kombinerat med drivrutin funktioner för att dela ett grafikkort mellan flera containrar.", + "deployment-gpu-quota": "GPU användnings kvoten är användarglobal, om din kvot tillåter flera GPUer kan du dela upp dom som du vill. Dvs antingen ett grafikkort per deployment, eller flera GPUer på en deployment.", + "deployment-gpu-unstable": "Denna funktion bygger på funktioner från NVIDIA som är i BETA, fel kan uppstå. Kontakta oss på discord om du har några frågor.", + "deployment-gpu-create-select-title": "Lägg till GPU resurser", + "clusters-overview": "Kluster", + "active-keys": "Aktiva nycklar", + "expired-keys": "Utgångna nycklar", + "delete-all-expired": "Ta bort alla utgågna", + "custom-deployment": "Egen", + "custom-deployment-description": "Bygg och använd din egna image, använd CI/CD via Github actions / Gitlab workflows eller bygg och pusha din egna image. Nycklar går att se när din deployment är skapad", + "deployment-spec-selector": "Välj specifikationer", + "deployment-ssh-info": "SSH:a till dina deployments med en privat nyckel kopplad till en offentlig nyckel i din profil", + "deployment-ssh-view-keys": "Se dina SSH nycklar i din profil" } } diff --git a/src/pages/admin/Admin.tsx b/src/pages/admin/Admin.tsx index b1cd0fed..28b81b7a 100644 --- a/src/pages/admin/Admin.tsx +++ b/src/pages/admin/Admin.tsx @@ -20,7 +20,7 @@ import { Typography, useTheme, } from "@mui/material"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../hooks/useKeycloak"; import { decode } from "js-base64"; import { enqueueSnackbar } from "notistack"; import { Fragment, useEffect, useState } from "react"; diff --git a/src/pages/admin/AdminV2.tsx b/src/pages/admin/AdminV2.tsx index a4b85d88..77a83fc9 100644 --- a/src/pages/admin/AdminV2.tsx +++ b/src/pages/admin/AdminV2.tsx @@ -1,5 +1,7 @@ import { + Box, Card, + Chip, Container, LinearProgress, Link, @@ -28,6 +30,7 @@ import { VmRead, } from "@kthcloud/go-deploy-types/types/v2/body"; import { + renderDeploymentGPU, renderResourceStatus, renderResourceWithGPU, renderShared, @@ -38,11 +41,22 @@ import { Resource, Uuid } from "../../types"; import AdminToolbar from "../../components/admin/AdminToolbar"; import HostsTab from "../../components/admin/HostsTab"; import { deleteDeployment } from "../../api/deploy/deployments"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../hooks/useKeycloak"; import { deleteVM } from "../../api/deploy/vms"; import { deleteGpuLease } from "../../api/deploy/gpuLeases"; import { deleteTeam } from "../../api/deploy/teams"; import TimeLeft from "../../components/admin/TimeLeft"; +import { + GpuClaimConsumer, + GpuClaimRead, + GpuClaimStatus, +} from "../../temporaryTypesRemoveMe"; +import Iconify from "../../components/Iconify"; +import Label from "../../components/Label"; +import TimeAgo from "../../components/admin/TimeAgo"; +import CluseterOverviewTab from "../../components/admin/ClusterOverviewTab"; +import { deleteGpuClaim } from "../../api/deploy/gpuClaims"; +import { getChipColor } from "../../components/admin/GPUClaimModal"; export default function AdminV2() { const { tab: initialTab } = useParams(); @@ -113,6 +127,9 @@ export default function AdminV2() { setGpuGroupsPage, gpuGroupsPageSize, setGpuGroupsPageSize, + + // GpuClaims + gpuClaims, } = useAdmin(); const navigate = useNavigate(); @@ -147,6 +164,7 @@ export default function AdminV2() { }, { id: "zone", label: "Zone" }, { id: "image", label: "Image", or: "Custom deployment" }, + { id: "specs.gpus", label: "GPUs", renderFunc: renderDeploymentGPU }, { id: "*", label: "Status", @@ -310,6 +328,195 @@ export default function AdminV2() { }, ], }, + { + label: "GPU Claims", + columns: [ + { id: "id", label: "ID" }, + { id: "name", label: "Name" }, + { id: "zone", label: "Zone" }, + { + id: "allowedRoles", + label: "Allowed Roles", + renderFunc: (allowedRoles: string[] | undefined) => { + return ( + + {allowedRoles ? ( + allowedRoles.map((role) => ( + + )) + ) : ( + + )} + + ); + }, + }, + { + id: "*", + label: "Requested", + renderFunc: (claim: GpuClaimRead | undefined) => { + const requested = claim?.requested; + const allocated = claim?.allocated; + + if (!requested || Object.keys(requested).length === 0) { + return ( + + {t("gpuclaim-no-gpu-requested")} + + ); + } + + return ( + + {Object.entries(requested).map(([name, req]) => { + const allocs = allocated?.[name]; + const vendor = + (req.config as any)?.type || + (req.config as any)?.driver || + "unknown"; + const sharing = (req.config as any)?.parameters?.sharing + ?.strategy; + const sharingConfig = vendor?.toLowerCase().includes("nvidia") + ? sharing?.includes("MPS") + ? (req.config as any)?.parameters?.mpsConfig + : (req.config as any)?.parameters?.timeslicingConfig + : undefined; + const allocatedChip = allocs ? ( + + {(allocs as Array).map((alloc) => ( + + ))} + + ) : ( + + ); + + return ( + + + + {allocatedChip} + + + + {req.count} {vendor} {sharing && `• ${sharing}`}{" "} + {sharingConfig && `• ${sharingConfig}`} + + + ); + })} + + ); + }, + }, + { + id: "consumers", + label: "Consumers", + renderFunc: (consumers: GpuClaimConsumer[] | undefined) => { + if (consumers == undefined) return <>; + return ( + + {consumers.map((c) => ( + + ))} + + ); + }, + }, + { + id: "status", + label: "Status", + renderFunc: (status: GpuClaimStatus) => { + const phase = status?.phase?.toLowerCase(); + + let color = "default"; + if (phase === "bound") color = "success"; + else if (phase === "pending") color = "info"; + else if (phase === "failed") color = "error"; + + return ( + + + {status?.lastSynced != undefined && ( + + )} + + ); + }, + }, + ], + actions: [ + { + label: t("button-delete"), + onClick: (claim: GpuClaimRead) => { + if (keycloak.token) deleteGpuClaim(keycloak.token, claim.id); + }, + withConfirm: true, + }, + ], + }, { label: "Users", columns: [ @@ -327,7 +534,7 @@ export default function AdminV2() { const calculatePercentage = (used: number, total: number) => total ? ((used / total) * 100).toFixed(1) : "0.0"; - const { cpu, ram, disk } = { + const { cpu, ram, disk, gpu } = { cpu: calculatePercentage( user.usage.cpuCores, user.quota.cpuCores @@ -337,6 +544,14 @@ export default function AdminV2() { user.usage.diskSize, user.quota.diskSize ), + gpu: + (user.usage as any).gpus != undefined && + (user.quota as any).gpus != undefined + ? calculatePercentage( + (user.usage as any).gpus, + (user.quota as any).gpus + ) + : undefined, }; return ( @@ -361,6 +576,20 @@ export default function AdminV2() { > + + {gpu && ( + <> + GPUs + + + + + )} ); }, @@ -443,6 +672,7 @@ export default function AdminV2() { {} ); tabLookup["hosts"] = resourceConfig.length; + tabLookup["overview"] = resourceConfig.length + 1; useEffect(() => { if ( initialTab && @@ -494,6 +724,14 @@ export default function AdminV2() { pageSize: gpuGroupsPageSize, setPageSize: setGpuGroupsPageSize, }, + { + data: gpuClaims, + setFilter: () => {}, + page: 0, + setPage: () => {}, + pageSize: gpuClaims?.length || 0, + setPageSize: () => {}, + }, { data: users, filter: usersFilter, @@ -555,6 +793,7 @@ export default function AdminV2() { /> )), , + , ]; useEffect(() => { @@ -588,6 +827,7 @@ export default function AdminV2() { ))} + {tabs[activeTab]} diff --git a/src/pages/create/Create.tsx b/src/pages/create/Create.tsx index 6e9a5d5d..97b5bc42 100644 --- a/src/pages/create/Create.tsx +++ b/src/pages/create/Create.tsx @@ -11,7 +11,7 @@ import { //hooks import { useEffect, useState } from "react"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../hooks/useKeycloak"; import { useSnackbar } from "notistack"; import useResource from "../../hooks/useResource"; @@ -37,7 +37,7 @@ export const Create = () => { const { initialized } = useKeycloak(); const { t } = useTranslation(); const { enqueueSnackbar } = useSnackbar(); - const { queueJob } = useResource(); + const { queueJob, user } = useResource(); const [alignment, _setAlignment] = useState(""); const setAlignment = (newAlignment: string) => { _setAlignment(newAlignment); @@ -118,7 +118,10 @@ export const Create = () => { + +
    + + {(!gpus || gpus.length === 0) && ( + <> + + + theme.palette.action.hover, + textAlign: "center", + }} + > + + {t("deployment-gpu-none")} + + + + + )} + + {gpus?.map((gpu, index) => { + const isValid = validateGPU(gpu); + + const handleChange = ( + index: number, + field: keyof DeploymentGPU, + value: string + ) => { + const unset = field === "name" ? "" : undefined; + const trimmed = value.trim(); + setGpus((prev) => { + const updated = [...(prev || [])]; + updated[index] = { + ...updated[index], + [field]: trimmed === "" ? unset : trimmed, + }; + return updated; + }); + }; + + return ( + <> + + + { + handleChange( + index, + "claimName", + newValue ? newValue : "" + ); + }} + options={ + gpuClaims + ?.filter((c) => c.zone == resource.zone) + .map((c) => c.name) || [] + } + getOptionLabel={(option) => option} + renderInput={(params) => ( + + )} + isOptionEqualToValue={(option, value) => + option === value + } + disableClearable + /> + {gpu.claimName !== "" && + (() => { + const options = Object.keys( + gpuClaims?.find( + (g) => g.name === gpu.claimName + )?.requested ?? {} + ); + + // Automatically select if only one option + const selectedValue = + options.length === 1 + ? options[0] + : gpu.name || ""; + + if (selectedValue !== gpu.name) { + handleChange( + index, + "name", + selectedValue ?? "" + ); + } + + return ( + { + handleChange( + index, + "name", + newValue ?? "" + ); + }} + options={options} + getOptionLabel={(option) => option} + renderInput={(params) => ( + + )} + isOptionEqualToValue={(option, value) => + option === value + } + disableClearable + /> + ); + })()} + + + + setGpus((prev) => + prev?.filter((_, i) => i !== index) + ) + } + > + + + + + + + ); + })} +
    +
    + theme.palette.action.hover, + borderRadius: "1rem", + }} + > + + {gpus?.some((g) => !validateGPU(g)) && ( + + {t("deployment-gpu-invalid-config-warning")} + + )} + + + + )}
    )} @@ -602,3 +930,20 @@ export const Specs = ({ resource }: { resource: Resource }) => { ); }; + +const gpusEqual = (a?: DeploymentGPU[], b?: DeploymentGPU[]): boolean => { + if (!a && !b) return true; + if (!a || !b) return false; + if (a.length !== b.length) return false; + + return a.every((gpuA, index) => { + const gpuB = b[index]; + if (!gpuB) return false; + + const keys = new Set([...Object.keys(gpuA), ...Object.keys(gpuB)]); + for (const key of keys) { + if ((gpuA as any)[key] !== (gpuB as any)[key]) return false; + } + return true; + }); +}; diff --git a/src/pages/edit/deployments/DeploymentCommands.tsx b/src/pages/edit/deployments/DeploymentCommands.tsx index 59f48cba..63e29983 100644 --- a/src/pages/edit/deployments/DeploymentCommands.tsx +++ b/src/pages/edit/deployments/DeploymentCommands.tsx @@ -1,5 +1,5 @@ import { Button, Stack, Link } from "@mui/material"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../../hooks/useKeycloak"; import { enqueueSnackbar } from "notistack"; import { useNavigate } from "react-router-dom"; import { diff --git a/src/pages/edit/deployments/DomainManager.tsx b/src/pages/edit/deployments/DomainManager.tsx index 267b4974..eb6f558f 100644 --- a/src/pages/edit/deployments/DomainManager.tsx +++ b/src/pages/edit/deployments/DomainManager.tsx @@ -26,7 +26,7 @@ import { useMediaQuery, useTheme, } from "@mui/material"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../../hooks/useKeycloak"; import { enqueueSnackbar } from "notistack"; import { useEffect, useState } from "react"; import { updateDeployment } from "../../../api/deploy/deployments"; diff --git a/src/pages/edit/deployments/EnvManager.tsx b/src/pages/edit/deployments/EnvManager.tsx index f8488b34..3b0ac020 100644 --- a/src/pages/edit/deployments/EnvManager.tsx +++ b/src/pages/edit/deployments/EnvManager.tsx @@ -20,7 +20,7 @@ import Iconify from "../../../components/Iconify"; import { updateDeployment } from "../../../api/deploy/deployments"; import { enqueueSnackbar } from "notistack"; import useResource from "../../../hooks/useResource"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../../hooks/useKeycloak"; import { errorHandler } from "../../../utils/errorHandler"; import { useTranslation } from "react-i18next"; import { Deployment } from "../../../types"; diff --git a/src/pages/edit/deployments/GHActions.tsx b/src/pages/edit/deployments/GHActions.tsx index e8925f56..614fc73e 100644 --- a/src/pages/edit/deployments/GHActions.tsx +++ b/src/pages/edit/deployments/GHActions.tsx @@ -1,5 +1,5 @@ import { getDeploymentYaml } from "../../../api/deploy/deployments"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../../hooks/useKeycloak"; import { useEffect, useState } from "react"; import { parse } from "yaml"; diff --git a/src/pages/edit/deployments/HealthCheckRoute.tsx b/src/pages/edit/deployments/HealthCheckRoute.tsx index 4fd65e56..446a7bce 100644 --- a/src/pages/edit/deployments/HealthCheckRoute.tsx +++ b/src/pages/edit/deployments/HealthCheckRoute.tsx @@ -1,4 +1,4 @@ -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../../hooks/useKeycloak"; import { enqueueSnackbar } from "notistack"; import { useState } from "react"; import { useTranslation } from "react-i18next"; diff --git a/src/pages/edit/deployments/ImageManager.tsx b/src/pages/edit/deployments/ImageManager.tsx index 1edbd1f0..02d3078f 100644 --- a/src/pages/edit/deployments/ImageManager.tsx +++ b/src/pages/edit/deployments/ImageManager.tsx @@ -1,6 +1,6 @@ import { LoadingButton } from "@mui/lab"; import { Card, CardContent, CardHeader, Stack, TextField } from "@mui/material"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../../hooks/useKeycloak"; import { enqueueSnackbar } from "notistack"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; diff --git a/src/pages/edit/deployments/LogsView.tsx b/src/pages/edit/deployments/LogsView.tsx index 5ca172c6..986d31bf 100644 --- a/src/pages/edit/deployments/LogsView.tsx +++ b/src/pages/edit/deployments/LogsView.tsx @@ -8,7 +8,7 @@ import { Switch, Typography, } from "@mui/material"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../../hooks/useKeycloak"; import { useEffect, useRef, useState } from "react"; import Iconify from "../../../components/Iconify"; import polyfilledEventSource from "@sanity/eventsource"; diff --git a/src/pages/edit/deployments/PrivateMode.tsx b/src/pages/edit/deployments/PrivateMode.tsx index 1d4909de..f9180bcc 100644 --- a/src/pages/edit/deployments/PrivateMode.tsx +++ b/src/pages/edit/deployments/PrivateMode.tsx @@ -10,7 +10,7 @@ import { import { enqueueSnackbar } from "notistack"; import { Dispatch, SetStateAction, useEffect, useState } from "react"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../../hooks/useKeycloak"; import useResource from "../../../hooks/useResource"; import { updateDeployment } from "../../../api/deploy/deployments"; import { errorHandler } from "../../../utils/errorHandler"; diff --git a/src/pages/edit/deployments/SSHString.tsx b/src/pages/edit/deployments/SSHString.tsx new file mode 100644 index 00000000..f9ba9fe6 --- /dev/null +++ b/src/pages/edit/deployments/SSHString.tsx @@ -0,0 +1,82 @@ +import { + Card, + CardContent, + CardHeader, + IconButton, + Skeleton, + Stack, + Tooltip, + Typography, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import CopyButton from "../../../components/CopyButton"; +import { Deployment } from "../../../types"; +import Iconify from "../../../components/Iconify"; +import { useNavigate } from "react-router-dom"; + +const SSHString = ({ deployment }: { deployment: Deployment }) => { + const navigate = useNavigate(); + const { t } = useTranslation(); + const sshBase = + import.meta.env.VITE_DEPLOYMENT_SSH_BASE ?? window.location.hostname; + + const ssh = `ssh ${deployment.name}@${sshBase}`; + + return ( + + + + navigate("/profile")} + sx={{ fontSize: 20 }} + > + + + + + + {t("deployment-ssh-info")} + + + } + > + + + + } + /> + + {!ssh ? ( + + ) : ( + + + + {ssh} + + + + + )} + + + ); +}; + +export default SSHString; diff --git a/src/pages/edit/deployments/StorageManager.tsx b/src/pages/edit/deployments/StorageManager.tsx index c4f3e679..a99bbd1a 100644 --- a/src/pages/edit/deployments/StorageManager.tsx +++ b/src/pages/edit/deployments/StorageManager.tsx @@ -20,7 +20,7 @@ import { enqueueSnackbar } from "notistack"; import { useState } from "react"; import { updateDeployment } from "../../../api/deploy/deployments"; import Iconify from "../../../components/Iconify"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../../hooks/useKeycloak"; import useResource from "../../../hooks/useResource"; import { errorHandler } from "../../../utils/errorHandler"; import { useTranslation } from "react-i18next"; diff --git a/src/pages/edit/vms/PortManager.tsx b/src/pages/edit/vms/PortManager.tsx index 8a51c574..b9fd405e 100644 --- a/src/pages/edit/vms/PortManager.tsx +++ b/src/pages/edit/vms/PortManager.tsx @@ -25,7 +25,7 @@ import Iconify from "../../../components/Iconify"; import { enqueueSnackbar } from "notistack"; import useResource from "../../../hooks/useResource"; import { updateVM } from "../../../api/deploy/vms"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../../hooks/useKeycloak"; import { errorHandler } from "../../../utils/errorHandler"; import { useTranslation } from "react-i18next"; import CopyButton from "../../../components/CopyButton"; diff --git a/src/pages/edit/vms/ProxyManager.tsx b/src/pages/edit/vms/ProxyManager.tsx index f3604192..1019b8fe 100644 --- a/src/pages/edit/vms/ProxyManager.tsx +++ b/src/pages/edit/vms/ProxyManager.tsx @@ -29,7 +29,7 @@ import { useMediaQuery, useTheme, } from "@mui/material"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../../hooks/useKeycloak"; import { enqueueSnackbar } from "notistack"; import { useEffect, useState } from "react"; import { updateVM } from "../../../api/deploy/vms"; diff --git a/src/pages/edit/vms/VMCommands.tsx b/src/pages/edit/vms/VMCommands.tsx index 0a53d590..e6b5f0ae 100644 --- a/src/pages/edit/vms/VMCommands.tsx +++ b/src/pages/edit/vms/VMCommands.tsx @@ -1,5 +1,5 @@ import { Button, Stack } from "@mui/material"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../../hooks/useKeycloak"; import { sentenceCase } from "change-case"; import { enqueueSnackbar } from "notistack"; import { useTranslation } from "react-i18next"; diff --git a/src/pages/gpu/GPU.tsx b/src/pages/gpu/GPU.tsx index bfb9e71e..7de57b12 100644 --- a/src/pages/gpu/GPU.tsx +++ b/src/pages/gpu/GPU.tsx @@ -24,7 +24,7 @@ import { Typography, } from "@mui/material"; import Iconify from "../../components/Iconify"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../hooks/useKeycloak"; import { Uuid } from "../../types"; import { errorHandler } from "../../utils/errorHandler"; import { enqueueSnackbar } from "notistack"; diff --git a/src/pages/inbox/Inbox.tsx b/src/pages/inbox/Inbox.tsx index 40eb6217..70e19b06 100644 --- a/src/pages/inbox/Inbox.tsx +++ b/src/pages/inbox/Inbox.tsx @@ -16,7 +16,7 @@ import { TableRow, Typography, } from "@mui/material"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../hooks/useKeycloak"; import { enqueueSnackbar } from "notistack"; import { Fragment, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; diff --git a/src/pages/landing/Landing.tsx b/src/pages/landing/Landing.tsx index 7974a65f..a0e9b0da 100644 --- a/src/pages/landing/Landing.tsx +++ b/src/pages/landing/Landing.tsx @@ -2,24 +2,27 @@ import Hero from "./components/hero/Hero"; import Intro from "./components/intro/Intro"; import Page from "../../components/Page"; import { Box, Container } from "@mui/material"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../hooks/useKeycloak"; import LoadingPage from "../../components/LoadingPage"; import Funding from "./components/funding/Funding"; import Maia from "./components/maia/Maia"; import { AlertList } from "../../components/AlertList"; -import { useContext, useEffect } from "react"; -import { AuthContextWrapper } from "../../contexts/AuthContextWrapper"; +import { useEffect } from "react"; + import { enqueueSnackbar } from "notistack"; import { useTranslation } from "react-i18next"; +import { useAuth } from "react-oidc-context"; export function Landing() { const { keycloak, initialized } = useKeycloak(); - const { error } = useContext(AuthContextWrapper); + const { error } = useAuth(); const { t } = useTranslation(); useEffect(() => { if (error) { - enqueueSnackbar(t("error-connecting-to-iam"), { variant: "error" }); + enqueueSnackbar(t("error-connecting-to-iam") + ": " + error.message, { + variant: "error", + }); } }, [error, enqueueSnackbar]); diff --git a/src/pages/landing/components/funding/Funding.tsx b/src/pages/landing/components/funding/Funding.tsx index 800deda2..46ab9393 100644 --- a/src/pages/landing/components/funding/Funding.tsx +++ b/src/pages/landing/components/funding/Funding.tsx @@ -13,7 +13,7 @@ const Funding = () => { return ( - + diff --git a/src/pages/landing/components/hero/Hero.tsx b/src/pages/landing/components/hero/Hero.tsx index 346f17f7..3aa35822 100644 --- a/src/pages/landing/components/hero/Hero.tsx +++ b/src/pages/landing/components/hero/Hero.tsx @@ -9,7 +9,7 @@ import { import "./hero.css"; import { useEffect, useState } from "react"; import { fShortenNumber } from "../../../../utils/formatNumber"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../../../hooks/useKeycloak"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { TimestampedSystemCapacities } from "@kthcloud/go-deploy-types/types/v2/body"; @@ -49,10 +49,12 @@ const Hero = () => { { size={"large"} onClick={() => { if (!initialized) return; - keycloak.login({ - redirectUri: window.location.origin + "/deploy", - }); + keycloak.login(); }} > {t("button-login")} @@ -113,9 +113,8 @@ const Hero = () => { { { opacity: capacitiesLoading ? 0 : 1, }} > - + { {t("landing-hero-gpu")} - + { {t("landing-hero-cpu")} - + { }, }} > - + { - + { - if (child.material) child.material.metalness = 0; + const { scene } = useGLTF(url); + scene.traverse((obj) => { + if (obj.material) obj.material.metalness = 0; }); - return ; + return ; } export function BrainMesh({ mobile, position, props }) { @@ -42,7 +43,8 @@ export function BrainMesh({ mobile, position, props }) { let y = 1 - mouseCoordinates.y / window.innerHeight; const vector = new Vector3(x, y, 0); vector.unproject(camera); - meshRef.current.rotation.set(1 - vector.y * 20, vector.x * 10, 0); + //meshRef.current.rotation.set(1 - vector.y * 20, vector.x * 10, 0); + meshRef.current.rotation.set(1 - vector.y - 0.8, vector.x, 0); } }); diff --git a/src/pages/landing/components/maia/Maia.tsx b/src/pages/landing/components/maia/Maia.tsx index c7acdc0e..f672fd72 100644 --- a/src/pages/landing/components/maia/Maia.tsx +++ b/src/pages/landing/components/maia/Maia.tsx @@ -34,10 +34,9 @@ const Maia = () => { > {/* Text block - comes first on all screen sizes */} { {/* Brain block - comes second on desktop, second (below) on mobile */} - + {/* Desktop brain */} { {t("onboarding-deployments-2")} + + + {t("onboarding-deployments-3")} + diff --git a/src/pages/profile/ApiKeys.tsx b/src/pages/profile/ApiKeys.tsx index e02b3dd7..c47d44cf 100644 --- a/src/pages/profile/ApiKeys.tsx +++ b/src/pages/profile/ApiKeys.tsx @@ -33,14 +33,13 @@ import { CustomTheme } from "../../theme/types"; import { createApiKey, updateUser } from "../../api/deploy/users"; import { errorHandler } from "../../utils/errorHandler"; import { enqueueSnackbar } from "notistack"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../hooks/useKeycloak"; import { ApiKeyCreated } from "@kthcloud/go-deploy-types/types/v2/body"; import CopyButton from "../../components/CopyButton"; import { NoWrapTable as Table } from "../../components/NoWrapTable"; - export const ApiKeys = () => { const { t } = useTranslation(); - const { user } = useResource(); + const { user, setUser } = useResource(); const theme: CustomTheme = useTheme(); const { keycloak, initialized } = useKeycloak(); @@ -51,17 +50,26 @@ export const ApiKeys = () => { const [dialogOpen, setDialogOpen] = useState(false); const [newKey, setNewKey] = useState(""); const [loading, setLoading] = useState([]); + const [bulkLoading, setBulkLoading] = useState(false); + + const now = new Date(); + + if (!user) return null; + + const activeKeys = user.apiKeys.filter( + (key) => key.expiresAt && new Date(key.expiresAt) > now + ); + const expiredKeys = user.apiKeys.filter( + (key) => key.expiresAt && new Date(key.expiresAt) <= now + ); const createKey = async () => { - if (!(user && initialized && keycloak.token)) { - return; - } + if (!(user && initialized && keycloak.token)) return; - // Calculate ISO date from selected option const newKeyExpiresDate = new Date(); - const option = newKeyExpires.split(" "); - const amount = parseInt(option[0]); - const unit = option[1]; + const [amountStr, unit] = newKeyExpires.split(" "); + const amount = parseInt(amountStr); + switch (unit) { case "days": newKeyExpiresDate.setDate(newKeyExpiresDate.getDate() + amount); @@ -69,21 +77,17 @@ export const ApiKeys = () => { case "year": newKeyExpiresDate.setFullYear(newKeyExpiresDate.getFullYear() + amount); break; - default: - break; } const isoDate = newKeyExpiresDate.toISOString(); try { - // Create the key const response: ApiKeyCreated = await createApiKey( keycloak.token, user.id, newKeyName, isoDate ); - if (response) { setNewKey(response.key); setNewKeyName(""); @@ -92,46 +96,86 @@ export const ApiKeys = () => { } } catch (error: any) { errorHandler(error).forEach((e) => - enqueueSnackbar(t("could-not-fetch-profile") + e, { - variant: "error", - }) + enqueueSnackbar(t("could-not-fetch-profile") + e, { variant: "error" }) ); } }; const deleteKey = async (keyName: string) => { - if (!(user && initialized && keycloak.token)) { - return; - } - setLoading([...loading, keyName]); + if (!(user && initialized && keycloak.token)) return; + setLoading([...loading, keyName]); try { - const response = await updateUser(user.id, keycloak.token, { + await updateUser(user.id, keycloak.token, { + apiKeys: user.apiKeys.filter((k) => k.name !== keyName), + }); + enqueueSnackbar(t("successfully-updated"), { variant: "success" }); + setUser({ + ...user, apiKeys: user.apiKeys.filter((k) => k.name !== keyName), }); - if (response) { - enqueueSnackbar(t("successfully-updated"), { - variant: "success", - }); - } } catch (error: any) { errorHandler(error).forEach((e) => - enqueueSnackbar(t("could-not-fetch-profile") + e, { - variant: "error", - }) + enqueueSnackbar(t("could-not-fetch-profile") + e, { variant: "error" }) ); + } finally { setLoading(loading.filter((k) => k !== keyName)); } }; + const deleteExpired = async () => { + if (!(user && initialized && keycloak.token)) return; + setBulkLoading(true); + const expiredNames = expiredKeys.map((k) => k.name); + try { + await updateUser(user.id, keycloak.token, { + apiKeys: user.apiKeys.filter((k) => !expiredNames.includes(k.name)), + }); + enqueueSnackbar(t("successfully-updated"), { variant: "success" }); + setUser({ + ...user, + apiKeys: user.apiKeys.filter((k) => !expiredNames.includes(k.name)), + }); + } catch (error: any) { + errorHandler(error).forEach((e) => + enqueueSnackbar(t("could-not-fetch-profile") + e, { variant: "error" }) + ); + } finally { + setBulkLoading(false); + } + }; + const renderExpireSpan = (option: string) => { const chunks = option.split(" "); return chunks[0] + " " + t(chunks[1]); }; - if (!user) { - return null; - } + const renderKeyRow = (key: (typeof user.apiKeys)[0]) => ( + + {!loading.includes(key.name) ? ( + <> + + {key.name} + + + {key.expiresAt?.replace("T", " ").replace("Z", "").split(".")[0]} + + + deleteKey(key.name)}> + + + + + ) : ( + + + + )} + + ); return ( <> @@ -141,10 +185,7 @@ export const ApiKeys = () => { slots={{ backdrop: Backdrop }} slotProps={{ backdrop: { - sx: { - background: "rgba(0, 0, 0, 0.4)", - backdropFilter: "blur(3px)", - }, + sx: { background: "rgba(0,0,0,0.4)", backdropFilter: "blur(3px)" }, }, }} > @@ -189,101 +230,111 @@ export const ApiKeys = () => { } /> - - - - - {t("admin-name")} - {t("expires")} - {t("admin-actions")} - - - - {user.apiKeys - .filter((key) => key.name) - .map((key, index) => ( - - {!loading.includes(key.name) ? ( - <> - - {key.name} - - - { - key.expiresAt - .replace("T", " ") - .replace("Z", "") - .split(".")[0] - } - - - deleteKey(key.name)} - > - - - - - ) : ( - - - - )} + + {t("active-keys")} + +
    + + + {t("admin-name")} + {t("expires")} + {t("admin-actions")} + + + + {activeKeys.length > 0 ? ( + activeKeys.map(renderKeyRow) + ) : ( + + + {t("nothing-to-see-here")} + - ))} + )} + +
    +
    - 0 && ( + <> + - - { - setNewKeyName(e.target.value); - }} - /> - - - - - - - - - - - - - + {t("delete-all-expired")} + + )} + + + + + + {t("admin-name")} + {t("expires")} + + {t("admin-actions")} + + + + {expiredKeys.map(renderKeyRow)} +
    +
    + + )} + + + + + + + setNewKeyName(e.target.value)} + /> + + + + + + + + + + + +
    +
    +
    diff --git a/src/pages/profile/Profile.tsx b/src/pages/profile/Profile.tsx index bcf5839a..971a7579 100644 --- a/src/pages/profile/Profile.tsx +++ b/src/pages/profile/Profile.tsx @@ -7,7 +7,7 @@ import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import Paper from "@mui/material/Paper"; import LoadingPage from "../../components/LoadingPage"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../hooks/useKeycloak"; import { useSnackbar } from "notistack"; import { sentenceCase } from "change-case"; import CopyButton from "../../components/CopyButton"; diff --git a/src/pages/profile/ResetOnboarding.tsx b/src/pages/profile/ResetOnboarding.tsx index a9628590..3f51212f 100644 --- a/src/pages/profile/ResetOnboarding.tsx +++ b/src/pages/profile/ResetOnboarding.tsx @@ -1,6 +1,6 @@ import { LoadingButton } from "@mui/lab"; import { Card, CardActions, CardHeader, CircularProgress } from "@mui/material"; -import { useKeycloak } from "@react-keycloak/web"; +import { useKeycloak } from "../../hooks/useKeycloak"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; diff --git a/src/pages/profile/UserQuotas.tsx b/src/pages/profile/UserQuotas.tsx index ff7b8551..8506c2bd 100644 --- a/src/pages/profile/UserQuotas.tsx +++ b/src/pages/profile/UserQuotas.tsx @@ -52,6 +52,29 @@ export const UserQuotas = ({ user }: { user: User }) => { } /> + {(user.usage as any).gpus != undefined && + (user.quota as any).gpus != undefined && ( + } + label={ + + {t("landing-hero-gpus")} + + {(user.usage as any).gpus + + "/" + + (user.quota as any).gpus} + + + } + /> + )} } diff --git a/src/pages/status/Status.tsx b/src/pages/status/Status.tsx index b8568c13..eb3a493a 100644 --- a/src/pages/status/Status.tsx +++ b/src/pages/status/Status.tsx @@ -265,7 +265,7 @@ export function Status() { {t("menu-status")} - + - + - + - + - + - + - + - + - + {posts.length > 0 && ( - + diff --git a/src/pages/teams/Teams.tsx b/src/pages/teams/Teams.tsx index a920a3c4..cd427d65 100644 --- a/src/pages/teams/Teams.tsx +++ b/src/pages/teams/Teams.tsx @@ -22,7 +22,6 @@ import { Tooltip, Typography, } from "@mui/material"; -import { useKeycloak } from "@react-keycloak/web"; import { sentenceCase } from "change-case"; import { enqueueSnackbar } from "notistack"; import { Fragment, useEffect, useState } from "react"; @@ -52,6 +51,7 @@ import { } from "@kthcloud/go-deploy-types/types/v2/body"; import { AlertList } from "../../components/AlertList"; import { NoWrapTable as Table } from "../../components/NoWrapTable"; +import { useKeycloak } from "../../hooks/useKeycloak"; const Teams = () => { const { user, teams, beginFastLoad } = useResource(); @@ -278,6 +278,7 @@ const Teams = () => { alignItems={"center"} useFlexGap > + {/* @ts-ignore idk why ts cant handle it */} {team.members.map((member) => member.gravatarUrl ? ( @@ -490,7 +491,6 @@ const Teams = () => { alignItems="center" > { )} { alignItems="flex-start" justifyContent={"flex-start"} > + + {(tier.permissions.includes("useVms") ? "✅ " : "❌ ") + + t("use-vms")} + { > {`🧠 ${t("memory")}: ${tier.quota.ram} GB`} + {(tier.quota as any).gpus != undefined && ( + + {`🤖 ${t("landing-hero-gpus")}: ${(tier.quota as any).gpus}`} + + )} { variant="outlined" color="primary" fullWidth - onClick={() => - keycloak.login({ - redirectUri: window.location.origin + "/deploy", - }) - } + onClick={() => keycloak.login()} sx={{ m: 1 }} > {t("button-login")} diff --git a/src/temporaryTypesRemoveMe.ts b/src/temporaryTypesRemoveMe.ts new file mode 100644 index 00000000..e7ed3057 --- /dev/null +++ b/src/temporaryTypesRemoveMe.ts @@ -0,0 +1,111 @@ +////////// +// source: gpu_claim.go + +/** + * GpuClaimRead is a detailed DTO for administrators + * providing full visibility into requested, allocated, + * and consumed GPU resources. + */ +export interface GpuClaimRead { + id: string; + name: string; + zone: string; + /** + * Roles allowed to use this GpuClaim, empty means all + */ + allowedRoles?: string[]; + /** + * Requested contains all requested GPU configurations by key (request.Name). + */ + requested?: Record; + /** + * Allocated contains the GPUs that have been successfully bound/allocated. + */ + allocated?: Record; + /** + * Consumers are the workloads currently using this claim. + */ + consumers?: GpuClaimConsumer[]; + /** + * Status reflects the reconciliation and/or lifecycle state. + */ + status?: GpuClaimStatus; + /** + * LastError holds the last reconciliation or provisioning error message. + */ + lastError?: string; + createdAt: string; + updatedAt?: string; +} +export interface GpuClaimCreate { + name: string; + zone?: string; + allowedRoles?: string[]; + /** + * Requested contains all requested GPU configurations by key (request.Name). + */ + requested?: RequestedGpuCreate[]; +} +export interface GpuClaimCreated { + id: string; + jobId: string; +} +export interface RequestedGpuCreate extends RequestedGpu { + name: string; +} +/** + * RequestedGpu describes the desired GPU configuration that was requested. + */ +export interface RequestedGpu { + allocationMode: string; + capacity?: { [key: string]: string }; + count?: number /* int64 */; + deviceClassName: string; + selectors?: string[]; + config?: GpuDeviceConfigurationWrapper; +} +export interface GpuDeviceConfigurationWrapper {} +/** + * GpuDeviceConfiguration represents a vendor-specific GPU configuration. + */ +export type GpuDeviceConfiguration = any /* json.Marshaler */; +/** + * GenericDeviceConfiguration is a catch-all configuration when no vendor-specific struct is used. + */ +export interface GenericDeviceConfiguration { + driver: string; +} +/** + * NvidiaDeviceConfiguration represents NVIDIA-specific configuration options. + */ +export interface NvidiaDeviceConfiguration { + driver: string; + parameters?: any /* nvidia.GpuConfig */; +} +/** + * AllocatedGpu represents a concrete allocated GPU or GPU share. + */ +export interface AllocatedGpu { + pool?: string; + device?: string; + shareID?: string; + adminAccess?: boolean; +} +/** + * GpuClaimConsumer describes a workload consuming this GPU claim. + */ +export interface GpuClaimConsumer { + apiGroup?: string; + resource?: string; + name?: string; + uid?: string; +} +/** + * GpuClaimStatus represents runtime state and metadata about allocation progress. + */ +export interface GpuClaimStatus { + phase?: string; + message?: string; + updatedAt?: string; + lastSynced?: string; +} diff --git a/src/theme/overrides/Backdrop.ts b/src/theme/overrides/Backdrop.ts index 708dd141..61b69a1a 100644 --- a/src/theme/overrides/Backdrop.ts +++ b/src/theme/overrides/Backdrop.ts @@ -9,6 +9,7 @@ export default function Backdrop(theme: CustomTheme) { MuiBackdrop: { styleOverrides: { root: { + backgroundColor: "rgb(22,28,36", background: [ `rgb(22,28,36)`, `-moz-linear-gradient(75deg, ${varLow} 0%, ${varHigh} 100%)`, diff --git a/src/types.ts b/src/types.ts index 3d952614..1db0242b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,6 +2,8 @@ import { UserRead, DeploymentRead, JobRead, + DeploymentSpecs, + Env, } from "@kthcloud/go-deploy-types/types/v2/body/index"; import { VmRead as V2VmRead, @@ -17,9 +19,19 @@ export interface Vm extends V2VmRead { type: "vm"; } +export interface DeploymentGPU { + name: string; + claimName: string; +} + +export interface DeploymentSpecsGPU extends DeploymentSpecs { + gpus?: DeploymentGPU[]; +} + export interface Deployment extends DeploymentRead { type: "deployment"; deploymentType?: string; + specs: DeploymentSpecsGPU; } export type Resource = Vm | Deployment; @@ -69,3 +81,7 @@ export type VmQueryParams = UserQueryParams & { export type GpuLeaseQueryParams = BaseQueryParams & { vmId?: string; }; + +export type EnvVar = Env; + +export type Visibility = "public" | "private" | "auth"; diff --git a/tsconfig.json b/tsconfig.json index 12fac75c..9fe9c0c4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,10 @@ "noFallthroughCasesInSwitch": true, "noImplicitAny": true, "noImplicitThis": true, - "strictNullChecks": true + "strictNullChecks": true, + + /* vite */ + "types": ["vite/client"] }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] diff --git a/vite.config.ts b/vite.config.ts index 1872fb43..e95d0a6d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,15 @@ import million from "million/compiler"; import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react-swc"; +import react from "@vitejs/plugin-react"; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [million.vite({ auto: true }), react()], + plugins: [ + million.vite({ auto: true }), + react({ + babel: { + plugins: [["babel-plugin-react-compiler"]], + }, + }), + ], });