diff --git a/.dockerignore b/.dockerignore index fc06e3e5..814e90a9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,7 +9,6 @@ LICENSE .vscode Makefile helm-charts -.env .editorconfig .idea coverage* diff --git a/.env b/.env index 204f0dcc..8be0764a 100644 --- a/.env +++ b/.env @@ -1,3 +1,11 @@ VITE_DEPLOY_API_URL="https://api.cloud.cbh.kth.se/deploy/v2" VITE_ALERT_API_URL="https://alert.app.cloud.cbh.kth.se/" +VITE_KEYCLOAK_URL="https://iam.cloud.cbh.kth.se" +VITE_KEYCLOAK_REALM="cloud" +VITE_KEYCLOAK_CLIENT_ID="landing" +VITE_RANCHER_URL="https://mgmt.cloud.cbh.kth.se" +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" GENERATE_SOURCEMAP=false diff --git a/.prettierignore b/.prettierignore index eab39f0f..fb83e8b6 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,4 +11,5 @@ public/_redirects package.json package-lock.json bun.lockb -*.xcf \ No newline at end of file +*.xcf +*.glb \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 656093d2..4005e58e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,54 @@ # Build with Bun -FROM docker.io/oven/bun:latest as build +FROM --platform=$BUILDPLATFORM docker.io/oven/bun:latest AS build ARG RELEASE_BRANCH ARG RELEASE_DATE ARG RELEASE_COMMIT +ENV NODE_ENV="production" + +WORKDIR /app + +COPY package*.json bun.lockb ./ + +RUN bun install + +COPY .env . +COPY --chmod=777 scripts/ ./ + +RUN ./docker-envs.ts .env.production && \ + ./nginx-entrypoint.ts && \ + rm .env + ENV VITE_RELEASE_BRANCH=${RELEASE_BRANCH} ENV VITE_RELEASE_DATE=${RELEASE_DATE} ENV VITE_RELEASE_COMMIT=${RELEASE_COMMIT} -ENV VITE_API_URL="https://api.cloud.cbh.kth.se" -ENV VITE_DEPLOY_API_URL="https://api.cloud.cbh.kth.se/deploy/v2" -ENV NODE_ENV="production" +COPY .eslintrc.json jsconfig.json index.html tsconfig*.json vite.config.ts ./ -WORKDIR /app -COPY . /app +COPY . . -RUN bun install RUN bun run build # Serve with NGINX -FROM nginx +FROM nginx:latest COPY --from=build /app/dist /usr/share/nginx/html RUN rm /etc/nginx/conf.d/default.conf COPY nginx/nginx.conf /etc/nginx/conf.d + +COPY --from=build --chmod=777 --link /app/entrypoint.sh . + +# Set default values, can be overriden +ENV DEPLOY_API_URL="https://api.cloud.cbh.kth.se/deploy/v2" +ENV ALERT_API_URL="https://alert.app.cloud.cbh.kth.se/" +ENV KEYCLOAK_URL="https://iam.cloud.cbh.kth.se" +ENV KEYCLOAK_REALM="cloud" +ENV KEYCLOAK_CLIENT_ID="landing" +ENV RANCHER_URL="https://mgmt.cloud.cbh.kth.se" +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" + EXPOSE 3000 -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index 8795de8a..c2cb278f 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,15 @@ Welcome to the kthcloud/console repository! This project is the web console for ## Table of Contents -- [☁️ kthcloud/console](#️-kthcloudconsole) - - [Table of Contents](#table-of-contents) - - [External dependencies](#external-dependencies) - - [Setup](#setup) - - [Contributing](#contributing) - - [Locales and translations](#locales-and-translations) - - [Other languages](#other-languages) - - [Formatting](#formatting) - - [License](#license) +- [☁️ kthcloud/console](#️-kthcloudconsole) + - [Table of Contents](#table-of-contents) + - [External dependencies](#external-dependencies) + - [Setup](#setup) + - [Contributing](#contributing) + - [Locales and translations](#locales-and-translations) + - [Other languages](#other-languages) + - [Formatting](#formatting) + - [License](#license) ## External dependencies @@ -20,14 +20,14 @@ console uses these services for its functionality. kthcloud maintained: -- [go-deploy](https://github.com/kthcloud/go-deploy): Backend for creation, and management of resources -- [alert](https://alert.app.cloud.cbh.kth.se/): Provides alerts -- [kthcloud iam](https://iam.cloud.cbh.kth.se): Provides user auth +- [go-deploy](https://github.com/kthcloud/go-deploy): Backend for creation, and management of resources +- [alert](https://alert.app.cloud.cbh.kth.se/): Provides alerts +- [kthcloud iam](https://iam.cloud.cbh.kth.se): Provides user auth External: -- [Gravatar](https://gravatar.com): Provides user avatars -- [Google Fonts](https://fonts.google.com): Provides fonts +- [Gravatar](https://gravatar.com): Provides user avatars +- [Google Fonts](https://fonts.google.com): Provides fonts ## Setup diff --git a/bun.lockb b/bun.lockb index 8e46bd01..29e9ecf8 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index df5ff2f8..b8ba10cd 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@iconify/react": "^4.1.1", - "@kthcloud/go-deploy-types": "^1.0.20", + "@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", @@ -29,6 +29,7 @@ "@types/punycode": "^2.1.4", "@types/three": "^0.164.1", "apexcharts": "^3.49.1", + "bun": "^1.2.22", "change-case": "^5.4.4", "crypto-js": "^4.2.0", "http-status-codes": "^2.3.0", @@ -57,6 +58,7 @@ }, "devDependencies": { "@faker-js/faker": "^8.4.1", + "@types/bun": "^1.1.17", "@types/react": "^18.3.3", "@types/react-copy-to-clipboard": "^5.0.7", "@types/react-dom": "^18.3.0", @@ -64,13 +66,13 @@ "@typescript-eslint/parser": "^7.13.0", "@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react-swc": "^3.7.0", - "eslint": "^9.4.0", + "eslint": "^9.35.0", "eslint-plugin-react": "^7.34.2", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.7", - "prettier": "^3.3.2", + "prettier": "^3.6.2", "prettier-plugin-nginx": "^1.0.3", "typescript": "^5.4.5", - "vite": "^5.3.0" + "vite": "^7.1.6" } -} \ No newline at end of file +} diff --git a/public/static/models/Brain.glb b/public/static/models/Brain.glb new file mode 100644 index 00000000..427173d9 Binary files /dev/null and b/public/static/models/Brain.glb differ diff --git a/scripts/docker-envs.ts b/scripts/docker-envs.ts new file mode 100755 index 00000000..8747736c --- /dev/null +++ b/scripts/docker-envs.ts @@ -0,0 +1,28 @@ +#!/usr/bin/env bun + +import fs from "fs"; +import path from "path"; + +// @ts-ignore +const viteEnv = import.meta.env; + +// Get the filename from command-line arguments +const args = process.argv.slice(2); +const outputFilename = args[0] || ".env.docker"; // Default to `.env.docker` if no argument is provided + +let envProductionContent = ""; + +// Process environment variables +for (const [key, _] of Object.entries(viteEnv)) { + if (key.startsWith("VITE_")) { + envProductionContent += `${key}={{__${key.slice("VITE_".length)}__}}\n`; + } +} + +// Resolve the output file path +const outputFilePath = path.resolve(outputFilename); + +// Write the content to the specified file +fs.writeFileSync(outputFilePath, envProductionContent, "utf-8"); + +console.log(`${outputFilename} file created successfully!`); diff --git a/scripts/nginx-entrypoint.ts b/scripts/nginx-entrypoint.ts new file mode 100755 index 00000000..7a3dcc36 --- /dev/null +++ b/scripts/nginx-entrypoint.ts @@ -0,0 +1,57 @@ +#!/usr/bin/env bun + +import fs from "fs"; +import path from "path"; + +const viteEnv = import.meta.env; + +let entrypointScript = `#!/bin/bash + +# Entrypoint script, replaces env variables in the dist files +set -eu + +exec "$@" + +# Function to update environment variables in files recursively +update_env_vars_placeholders() { + local directory="$1" + local old_var="$2" + local new_value="\${!3:-}" + + if [[ -z "$new_value" && -n "$3" ]]; then + echo "[WARN]: The value for $3 is not set." >&2 + fi + + # Find files in the given directory recursively + find "$directory" -type f -exec sed -i "s|{{$old_var}}|$new_value|g" {} + +}\n\n`; + +// Add the updates for all VITE_* env variables +for (const [key, _] of Object.entries(viteEnv)) { + if (key.startsWith("VITE_")) { + const env = key.slice("VITE_".length); + entrypointScript += `update_env_vars_placeholders "/usr/share/nginx/html" "__${env}__" "${env}"\n`; + } +} + +// Start Nginx +entrypointScript += `\n# Start Nginx\nnginx -g 'daemon off;'`; + +console.log(entrypointScript); + +// Get the filename from command-line arguments +const args = process.argv.slice(2); +const outputFilename = args[0] || "entrypoint.sh"; // Default to entrypoint.sh if no argument is provided + +// Define the output file path +const outputFilePath = path.resolve(outputFilename); + +// Write the entrypoint script to the specified file +fs.writeFileSync(outputFilePath, entrypointScript, "utf-8"); + +// Set the file permissions to 777 (executable for all users) +fs.chmodSync(outputFilePath, 0o777); + +console.log( + `${outputFilename} file created with executable permissions (777)!` +); diff --git a/src/App.tsx b/src/App.tsx index 21597977..2bc1a813 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,35 +14,38 @@ import { IconButton } from "@mui/material"; import Iconify from "./components/Iconify"; import { ThemeModeContextProvider } from "./contexts/ThemeModeContext"; import { AlertContextProvider } from "./contexts/AlertContext"; +import { AdminResourceContextProvider } from "./contexts/AdminResourceContext"; export default function App() { return ( - - ( - closeSnackbar(snack)} - color="inherit" - > - - - )} - dense - preventDuplicate - > - - - - - - - + + + ( + closeSnackbar(snack)} + color="inherit" + > + + + )} + dense + preventDuplicate + > + + + + + + + + diff --git a/src/Router.tsx b/src/Router.tsx index d87d9751..9abc0097 100644 --- a/src/Router.tsx +++ b/src/Router.tsx @@ -93,7 +93,7 @@ export default function Router() { ), }, { - path: "admin", + path: "admin/:tab?", element: ( diff --git a/src/api/deploy/deployments.ts b/src/api/deploy/deployments.ts index 28923cd6..610aa929 100644 --- a/src/api/deploy/deployments.ts +++ b/src/api/deploy/deployments.ts @@ -9,7 +9,11 @@ export const getDeployment = async (token: string, id: string) => { }, }); const response = [await res.json()]; - const result = response.map((obj) => ({ ...obj, type: "deployment" })); + const result = response.map((obj) => ({ + ...obj, + deploymentType: obj.type, + type: "deployment", + })); if (Array.isArray(result)) return result; else throw new Error("Error getting deployments, response was not an array"); }; diff --git a/src/api/deploy/hosts.ts b/src/api/deploy/hosts.ts new file mode 100644 index 00000000..e6f83d0c --- /dev/null +++ b/src/api/deploy/hosts.ts @@ -0,0 +1,25 @@ +import { HostVerboseRead } from "@kthcloud/go-deploy-types/types/v2/body"; +import { Uuid } from "../../types"; + +export const getHostsVerbose = async ( + token: Uuid +): Promise => { + const res = await fetch( + import.meta.env.VITE_DEPLOY_API_URL + "/hosts/verbose", + { + method: "GET", + headers: { + Authorization: "Bearer " + token, + }, + } + ); + + if (!res.ok) { + const body = await res.json(); + if (body) { + throw body; + } + throw res; + } + return await res.json(); +}; diff --git a/src/api/deploy/systemCapacities.ts b/src/api/deploy/systemCapacities.ts new file mode 100644 index 00000000..48942a3a --- /dev/null +++ b/src/api/deploy/systemCapacities.ts @@ -0,0 +1,29 @@ +import { SystemCapacities } from "@kthcloud/go-deploy-types/types/v2/body"; +import { Uuid } from "../../types"; + +export const getSystemCapacities = async ( + token: Uuid, + n?: number +): Promise => { + const res = await fetch( + import.meta.env.VITE_DEPLOY_API_URL + + "/systemCapacities" + + (n !== undefined ? "?n=" + n : ""), + { + method: "GET", + headers: { + Authorization: "Bearer " + token, + }, + } + ); + + if (!res.ok) { + const body = await res.json(); + if (body) { + throw body; + } + throw res; + } + const cap = await res.json(); + return cap.length > 0 ? (cap[0]?.capacities ?? undefined) : undefined; +}; diff --git a/src/api/deploy/users.ts b/src/api/deploy/users.ts index 1246aa09..fbd1f457 100644 --- a/src/api/deploy/users.ts +++ b/src/api/deploy/users.ts @@ -4,7 +4,8 @@ import { UserReadDiscovery, UserUpdate, } from "@kthcloud/go-deploy-types/types/v2/body"; -import { Jwt, Uuid } from "../../types"; +import { Jwt, UserQueryParams, Uuid } from "../../types"; +import { createQueryParams } from "../../utils/paguinationOpts"; export const getUser = async ( userId: string, @@ -153,3 +154,29 @@ export const discoverUserById = async ( } return await res.json(); }; + +export const getUsers = async ( + token: Uuid, + queryParams: UserQueryParams = { all: true, page: 1, pageSize: 10 } +): Promise => { + const res = await fetch( + import.meta.env.VITE_DEPLOY_API_URL + + "/users" + + createQueryParams(queryParams), + { + method: "GET", + headers: { + Authorization: "Bearer " + token, + }, + } + ); + + if (!res.ok) { + const body = await res.json(); + if (body) { + throw body; + } + throw res; + } + return await res.json(); +}; diff --git a/src/components/admin/AdminToolbar.tsx b/src/components/admin/AdminToolbar.tsx new file mode 100644 index 00000000..94d4341c --- /dev/null +++ b/src/components/admin/AdminToolbar.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import useAdmin from "../../hooks/useAdmin"; +import useInterval from "../../hooks/useInterval"; +import { useTranslation } from "react-i18next"; +import { + AppBar, + Box, + Button, + Stack, + Toolbar, + Typography, + useTheme, +} from "@mui/material"; + +export default function AdminToolbar() { + const { t } = useTranslation(); + const theme = useTheme(); + const { lastRefresh, refetch, loading, lastRefreshRtt } = useAdmin(); + const [timeDiffSinceLastRefresh, setTimeDiffSinceLastRefresh] = + useState(""); + useInterval(() => { + const now = new Date().getTime(); + const diff = now - lastRefresh; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) { + setTimeDiffSinceLastRefresh(hours + " " + t("time-hours-ago")); + return; + } + if (minutes > 0) { + setTimeDiffSinceLastRefresh(minutes + " " + t("time-minutes-ago")); + return; + } + + if (seconds > 0) { + setTimeDiffSinceLastRefresh(seconds + " " + t("time-seconds-ago")); + return; + } + + setTimeDiffSinceLastRefresh("0 " + t("time-seconds-ago")); + }, 1000); + + return ( + + + {t("admin-title")} + + + + + {loading ? ( + t("loading") + ) : ( + + RTT: + + {" " + lastRefreshRtt.toFixed(1) + " ms "} + + {t("admin-last-load")}: + + {" " + timeDiffSinceLastRefresh} + + + )} + + + + + ); +} diff --git a/src/components/admin/BlinkingLED.tsx b/src/components/admin/BlinkingLED.tsx new file mode 100644 index 00000000..155e6d7c --- /dev/null +++ b/src/components/admin/BlinkingLED.tsx @@ -0,0 +1,31 @@ +import { Box, styled } from "@mui/material"; + +const BlinkingLED = styled(Box, { + shouldForwardProp: (prop) => prop !== "status", +})<{ status: boolean }>(({ theme, status }) => ({ + width: 12, + height: 12, + borderRadius: "50%", + backgroundColor: status + ? theme.palette.success.main + : theme.palette.error.main, + animation: status ? "blinkGreen 1s infinite" : "blinkRed 2s infinite", + boxShadow: `0 0 1rem ${ + status ? theme.palette.success.main : theme.palette.error.main + }`, + + "@keyframes blinkGreen": { + "0%": { opacity: 1 }, + "50%": { opacity: 0.3 }, + "70%": { opacity: 0.9 }, + "100%": { opacity: 1 }, + }, + "@keyframes blinkRed": { + "0%": { opacity: 1 }, + "50%": { opacity: 0.3 }, + "70%": { opacity: 0.9 }, + "100%": { opacity: 1 }, + }, +})); + +export default BlinkingLED; diff --git a/src/components/admin/HostMachine.tsx b/src/components/admin/HostMachine.tsx new file mode 100644 index 00000000..58e583b4 --- /dev/null +++ b/src/components/admin/HostMachine.tsx @@ -0,0 +1,217 @@ +import { + HostCapacities, + HostVerboseRead, +} from "@kthcloud/go-deploy-types/types/v2/body"; +import { Box, Chip, Tooltip, Typography, useTheme } from "@mui/material"; +import BlinkingLED from "./BlinkingLED"; +import TimeLeft from "./TimeLeft"; +import TimeAgo from "./TimeAgo"; +import { useTranslation } from "react-i18next"; +import Iconify from "../Iconify"; + +export default function HostMachine({ + host, + specs, +}: { + host: HostVerboseRead; + specs?: HostCapacities; +}) { + const { t } = useTranslation(); + const theme = useTheme(); + + const currentlyDeactivated = + host.deactivatedUntil && + new Date(host.deactivatedUntil).getTime() - new Date().getTime() > 0; + + const hasIssue = !host.schedulable || currentlyDeactivated; + return ( + + + {host.displayName} + + + + + + {host.schedulable ? t("schedulable") : t("unschedulable")} + + + + {currentlyDeactivated && ( + + + + Deactivated + + + + + )} + + {specs && ( + + {specs.cpuCore && specs.cpuCore.total > 0 && ( + + } + label={ + + {t("landing-hero-cpu")} + + {specs.cpuCore.total} + + + } + /> + + )} + {specs.gpu && specs.gpu.total > 0 && ( + + } + label={ + + {t("resource-gpus")} + + {specs.gpu.total} + + + } + /> + + )} + {specs.ram && specs.ram.total > 0 && ( + + } + label={ + + {t("memory")} + + {specs.ram.total + " GB"} + + + } + /> + + )} + + )} + + + + {t("last-seen")}: + + + + + {t("registered-at")}: + + + + + + ); +} diff --git a/src/components/admin/HostsTab.tsx b/src/components/admin/HostsTab.tsx new file mode 100644 index 00000000..d90d3f3a --- /dev/null +++ b/src/components/admin/HostsTab.tsx @@ -0,0 +1,109 @@ +import { Box, Grid, Paper, Skeleton, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import useAdmin from "../../hooks/useAdmin"; +import HostMachine from "./HostMachine"; +import { + HostCapacities, + HostVerboseRead, + SystemCapacities, +} from "@kthcloud/go-deploy-types/types/v2/body"; + +const convertHostsToMap = (systemCapacities: SystemCapacities | undefined) => { + return ( + systemCapacities?.hosts?.reduce( + (map, host) => { + const key = `${host.name}_${host.zone}`; + map[key] = host; + return map; + }, + {} as Record + ) || {} + ); +}; + +export default function HostsTab() { + const { t } = useTranslation(); + const { hosts, systemCapacities } = useAdmin(); + + const hostsMap = systemCapacities ? convertHostsToMap(systemCapacities) : {}; + + const groupedByZone = hosts?.reduce( + (acc, host) => { + if (!acc[host.zone]) acc[host.zone] = { enabled: [], disabled: [] }; + host.enabled + ? acc[host.zone].enabled.push(host) + : acc[host.zone].disabled.push(host); + return acc; + }, + {} as Record< + string, + { enabled: HostVerboseRead[]; disabled: HostVerboseRead[] } + > + ); + + if (groupedByZone) { + Object.keys(groupedByZone).forEach((zone) => { + groupedByZone[zone].enabled.sort((a, b) => a.name.localeCompare(b.name)); + groupedByZone[zone].disabled.sort((a, b) => a.name.localeCompare(b.name)); + }); + } + + return ( + + {hosts === undefined ? ( + + {Array.from({ length: 9 }).map((_, index) => ( + + + + + + ))} + + ) : groupedByZone && Object.keys(groupedByZone).length > 0 ? ( + Object.entries(groupedByZone).map(([zone, { enabled, disabled }]) => ( + + + {t("zone")}: {zone} + + {enabled.length > 0 && ( + <> + + {t("enabled")} + + + {enabled.map((host) => ( + + + + ))} + + + )} + {disabled.length > 0 && ( + <> + + {t("disabled")} + + + {disabled.map((host) => ( + + + + ))} + + + )} + + )) + ) : ( + + {t("no-hosts-available")} + + )} + + ); +} diff --git a/src/components/admin/ResourceTab.tsx b/src/components/admin/ResourceTab.tsx new file mode 100644 index 00000000..cf47eec6 --- /dev/null +++ b/src/components/admin/ResourceTab.tsx @@ -0,0 +1,345 @@ +import { Dispatch, ReactNode, SetStateAction, useState } from "react"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Skeleton, + Typography, + Modal, + Button, + TablePagination, + useTheme, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import ConfirmButton from "../ConfirmButton"; +import SearchBar from "./SearchBar"; +import { Category, QueryModifier } from "./searchTypes"; +import { CustomTheme } from "../../theme/types"; + +interface ResourceTabProps { + resourceName: String; + data: T[] | undefined; + filteredData: T[] | undefined; + filter: string | undefined; + setFilter: Dispatch>; + columns?: { + id: keyof T | "*"; + label: string; + renderFunc?: (value: any) => ReactNode; + or?: string; + }[]; + actions?: { + label: string; + onClick: (obj: T) => void; + withConfirm?: boolean; + }[]; + OnClickModal?: React.ComponentType<{ data: T }>; + category?: Category; + setCategory?: Dispatch>; + queryModifier?: QueryModifier[Category]; + setQueryModifier?: Dispatch>; + page: number; + setPage: Dispatch>; + pageSize: number; + setPageSize: Dispatch>; +} + +const ResourceTab = ({ + resourceName, + data, + filteredData, + filter, + setFilter, + columns, + actions, + OnClickModal, + category, + setCategory, + queryModifier, + setQueryModifier, + page, + setPage, + pageSize, + setPageSize, +}: ResourceTabProps) => { + const { t } = useTranslation(); + const theme = useTheme(); + const loading = !data; + const resolvedColumns = + columns && columns.length > 0 + ? columns + : data && data.length > 0 + ? Object.keys(data[0]).map((key) => ({ + id: key as keyof T, + label: key.toString(), + renderFunc: undefined, + or: undefined, + })) + : []; + const resolvedActions = + columns && columns.length > 0 + ? [ + /*{ + label: t("details"), + onClick: (value: T) => setSelectedItem(value), + },*/ + ...(actions || []), + ] + : actions; + const [selectedItem, setSelectedItem] = useState(undefined); + + const handleChangePage = (_: any, newPage: number) => { + if (page !== newPage) setPage(newPage); + }; + + const handleChangeRowsPerPage = (event: any) => { + if (event.target.value === "all") { + setPageSize(-1); + } else { + setPageSize(parseInt(event.target.value, 10)); + } + setPage(0); + }; + + const currentData = filter ? filteredData : data; + const paginatedData = currentData?.slice( + page * pageSize, + page * pageSize + pageSize + ); + + // annoying TS compile issue for some reason + const modalBoxStyles: React.CSSProperties = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: "80vw", + height: "80vh", + backgroundColor: `${(theme as CustomTheme).palette.grey[500_32]} !important`, + boxShadow: "0px 4px 20px rgba(0, 0, 0, 0.1)", + padding: "1em", + borderRadius: "2em", + overflow: "auto", + }; + + return ( + + + {resourceName} + + {}} + category={category} + setCategory={setCategory} + queryModifier={queryModifier} + setQueryModifier={setQueryModifier} + /> + + {loading ? ( + + ) : ( + + + + + {resolvedColumns.map((col, index) => ( + + {col.label} + + ))} + {resolvedActions && ( + + {t("admin-actions")} + + )} + + + + {paginatedData?.map((row, index) => ( + + {resolvedColumns.map((col, j) => ( + + {(() => { + if (col.renderFunc) { + if (col.id === "*") { + return col.renderFunc(row); + } + return col.renderFunc( + typeof col.id === "string" + ? getNestedValue(row, col.id) + : row[col.id] + ); + } + const value = formatCellValue( + typeof col.id === "string" + ? getNestedValue(row, col.id) + : row[col.id] + ); + if (value === "undefined") { + return String(col.or); + } + + return value; + })()} + + ))} + {resolvedActions && ( + + {resolvedActions.map((action, j) => + action.withConfirm ? ( + { + action.onClick(row); + }} + props={{ + color: "error", + }} + /> + ) : ( + + ) + )} + + )} + + ))} + +
+
+ )} + + {selectedItem && OnClickModal ? ( + { + setSelectedItem(undefined); + }} + > +
+ +
+
+ ) : ( + selectedItem && ( + { + setSelectedItem(undefined); + }} + > +
+ + Item Details + + {selectedItem ? ( + + + + {Object.entries(selectedItem).map(([key, value]) => ( + + + {key} + + {formatCellValue(value)} + + ))} + +
+
+ ) : ( + No item selected. + )} +
+
+ ) + )} +
+ ); +}; + +const SkeletonTable = ({ columns }: { columns: number }) => ( + + + + + {[...Array(columns)].map((_, index) => ( + + + + ))} + + + + {[...Array(10)].map((_, rowIndex) => ( + + {[...Array(columns)].map((_, colIndex) => ( + + + + ))} + + ))} + +
+
+); + +const formatCellValue = (value: unknown): React.ReactNode => { + if (Array.isArray(value)) { + return ( +
    + {value.map((item, index) => ( +
  • {formatCellValue(item)}
  • + ))} +
+ ); + } else if (typeof value === "object" && value !== null) { + return ( + + + {Object.entries(value).map(([key, val]) => ( + + {key} + {formatCellValue(val)} + + ))} + +
+ ); + } else if (typeof value === "boolean") { + return value ? "Yes" : "No"; // Show booleans as Yes/No + } + return String(value); +}; + +const getNestedValue = (obj: any, path: string): any => { + return path.split(".").reduce((acc, key) => acc && acc[key], obj); +}; + +export default ResourceTab; diff --git a/src/components/admin/SearchBar.tsx b/src/components/admin/SearchBar.tsx new file mode 100644 index 00000000..d87e2996 --- /dev/null +++ b/src/components/admin/SearchBar.tsx @@ -0,0 +1,91 @@ +import React, { useState } from "react"; +import { + TextField, + InputAdornment, + Stack, + IconButton, + Tooltip, +} from "@mui/material"; +import SearchFilterPopup from "./SearchFilterPopup"; +import { Search, Sort } from "@mui/icons-material"; +import { Category, QueryModifier } from "./searchTypes"; + +type SearchBarProps = { + searchText?: string; + searchQuery: string; + setSearchQuery: (value: string) => void; + category?: Category; + queryModifier?: QueryModifier[Category]; + setCategory?: (value: Category) => void; + setQueryModifier?: (value: QueryModifier[Category]) => void; + onSearch: () => void; +}; + +const SearchBar: React.FC = ({ + searchText = "Search", + searchQuery, + setSearchQuery, + category, + queryModifier, + setCategory, + setQueryModifier, + onSearch, +}) => { + const [popupAnchor, setPopupAnchor] = useState(null); + + const handleFilterClick = (event: React.MouseEvent) => { + setPopupAnchor(popupAnchor ? null : event.currentTarget); // Toggle popup + }; + + const handleClosePopup = () => { + setPopupAnchor(null); + }; + + const renderFilter = + category != undefined && + setCategory != undefined && + queryModifier != undefined && + setQueryModifier != undefined; + + return ( + <> + + setSearchQuery(e.target.value)} + InputProps={{ + endAdornment: ( + + {renderFilter && ( + + + + + + )} + + + + + ), + }} + /> + + {renderFilter && ( + + )} + + ); +}; + +export default SearchBar; diff --git a/src/components/admin/SearchFilterPopup.tsx b/src/components/admin/SearchFilterPopup.tsx new file mode 100644 index 00000000..6da711ed --- /dev/null +++ b/src/components/admin/SearchFilterPopup.tsx @@ -0,0 +1,91 @@ +import { + Popper, + Paper, + ClickAwayListener, + Typography, + MenuItem, + MenuList, + Stack, + Divider, +} from "@mui/material"; +import { Category, QueryModifier } from "./searchTypes"; + +type SearchFilterPopupProps = { + anchorEl: HTMLElement | null; + onClose: () => void; + category: Category; + setCategory: (value: Category) => void; + queryModifier: QueryModifier[Category]; + setQueryModifier: (value: QueryModifier[Category]) => void; +}; + +export default function SearchFilterPopup({ + anchorEl, + onClose, + category, + setCategory, + queryModifier, + setQueryModifier, +}: SearchFilterPopupProps) { + const isOpen = Boolean(anchorEl); + + // Category options + const categoryOptions: Category[] = ["Matches", "User", "Attribute"]; + + // Query modifier options based on category + const queryModifierOptions: Record = { + Matches: [], + User: ["owns", "hasAccess"], + Attribute: ["resourceAttribute"], + }; + + return ( + + + + + {/* Category Selection */} +
+ Category + + {categoryOptions.map((option) => ( + setCategory(option)} + > + {option} + + ))} + +
+ + + {/* Query Modifier Selection */} + {(queryModifierOptions[category] || []).length > 0 && ( + <> +
+ Query Modifier + + {(queryModifierOptions[category] || []).map((option) => ( + + setQueryModifier(option as QueryModifier[Category]) + } + > + {option} + + ))} + +
+ + + )} +
+
+
+
+ ); +} diff --git a/src/components/admin/SearchStyle.tsx b/src/components/admin/SearchStyle.tsx new file mode 100644 index 00000000..fdf01972 --- /dev/null +++ b/src/components/admin/SearchStyle.tsx @@ -0,0 +1,18 @@ +import { OutlinedInput, styled } from "@mui/material"; +import { CustomTheme } from "../../theme/types"; + +export const SearchStyle = styled(OutlinedInput)(({ theme }) => ({ + width: 240, + transition: theme.transitions.create(["box-shadow", "width"], { + easing: theme.transitions.easing.easeInOut, + duration: theme.transitions.duration.shorter, + }), + "&.Mui-focused": { + width: 320, + boxShadow: (theme as CustomTheme).customShadows.z8, + }, + "& fieldset": { + borderWidth: `1px !important`, + borderColor: `${(theme as CustomTheme).palette.grey[500_32]} !important`, + }, +})); diff --git a/src/components/admin/TimeAgo.tsx b/src/components/admin/TimeAgo.tsx new file mode 100644 index 00000000..789e47aa --- /dev/null +++ b/src/components/admin/TimeAgo.tsx @@ -0,0 +1,50 @@ +import React, { useState, useEffect } from "react"; +import { Typography } from "@mui/material"; + +const TimeAgo: React.FC<{ createdAt: string | undefined }> = ({ + createdAt, +}) => { + const [timeAgo, setTimeAgo] = useState(""); + + const calculateTimeAgo = (createdAt: string) => { + const now = new Date().getTime(); + const createdDate = new Date(createdAt).getTime(); + const diff = now - createdDate; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `${days} day${days > 1 ? "s" : ""} ago`; + } + if (hours > 0) { + return `${hours} hour${hours > 1 ? "s" : ""} ago`; + } + if (minutes > 0) { + return `${minutes} minute${minutes > 1 ? "s" : ""} ago`; + } + if (seconds > 0) { + return `${seconds} second${seconds > 1 ? "s" : ""} ago`; + } + return "Just now"; + }; + + useEffect(() => { + if (createdAt) { + setTimeAgo(calculateTimeAgo(createdAt)); + + const interval = setInterval(() => { + if (createdAt) { + setTimeAgo(calculateTimeAgo(createdAt)); + } + }, 1000); + + return () => clearInterval(interval); // Clean up the interval on component unmount + } + }, [createdAt]); + + return {timeAgo}; +}; + +export default TimeAgo; diff --git a/src/components/admin/TimeLeft.tsx b/src/components/admin/TimeLeft.tsx new file mode 100644 index 00000000..23adfe87 --- /dev/null +++ b/src/components/admin/TimeLeft.tsx @@ -0,0 +1,53 @@ +import React, { useState, useEffect } from "react"; +import { Typography } from "@mui/material"; + +const TimeLeft: React.FC<{ targetDate: string | undefined }> = ({ + targetDate, +}) => { + const [timeLeft, setTimeLeft] = useState(""); + + const calculateTimeLeft = (targetDate: string) => { + const now = new Date().getTime(); + const targetTime = new Date(targetDate).getTime(); + const diff = targetTime - now; + + if (diff <= 0) return "Expired"; + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return `${days} day${days > 1 ? "s" : ""} left`; + } + if (hours > 0) { + return `${hours} hour${hours > 1 ? "s" : ""} left`; + } + if (minutes > 0) { + return `${minutes} minute${minutes > 1 ? "s" : ""} left`; + } + if (seconds > 0) { + return `${seconds} second${seconds > 1 ? "s" : ""} left`; + } + return "Just now"; + }; + + useEffect(() => { + if (targetDate) { + setTimeLeft(calculateTimeLeft(targetDate)); + + const interval = setInterval(() => { + if (targetDate) { + setTimeLeft(calculateTimeLeft(targetDate)); + } + }, 1000); + + return () => clearInterval(interval); + } + }, [targetDate]); + + return {timeLeft}; +}; + +export default TimeLeft; diff --git a/src/components/admin/searchTypes.ts b/src/components/admin/searchTypes.ts new file mode 100644 index 00000000..68db1690 --- /dev/null +++ b/src/components/admin/searchTypes.ts @@ -0,0 +1,13 @@ +export type Category = "Matches" | "User" | "Attribute"; + +export type QueryModifier = { + Matches: ""; + User: "owns" | "hasAccess"; + Attribute: "resourceAttribute"; +}; + +export type QueryParameters = { + matches: string; + byUserName: string; + resourceAttribute: "stale" | "shared"; +}; diff --git a/src/components/render/Resource.tsx b/src/components/render/Resource.tsx new file mode 100644 index 00000000..fd582e5b --- /dev/null +++ b/src/components/render/Resource.tsx @@ -0,0 +1,337 @@ +import { Link, Stack, Tooltip } from "@mui/material"; +import { Deployment, Resource, Vm } from "../../types"; +import { Link as RouterLink } from "react-router-dom"; +import Iconify from "../Iconify"; +import Label from "../Label"; +import { + GpuGroupRead, + ZoneRead, +} from "@kthcloud/go-deploy-types/types/v2/body"; +import { ThemeColor } from "../../theme/types"; +import { sentenceCase } from "change-case"; +import { getReasonPhrase } from "http-status-codes"; +import { TFunction } from "i18next"; +import { getDaysLeftUntilStale } from "../../utils/staleDates"; + +export const renderResourceButtons = (resource: Resource) => { + if ( + resource.type === "deployment" && + Object.hasOwn(resource, "url") && + (resource as Deployment).url !== "" && + (resource as Deployment).private === false + ) { + return ( + + + + + + + + + ); + } else { + return ( + + + + ); + } +}; + +export const renderResourceWithGPU = ( + resource: Resource, + gpuGroups: GpuGroupRead[] +) => { + if (resource.type === "vm" && (resource as Vm).gpu) { + const group = gpuGroups?.find( + (x) => x.id === (resource as Vm).gpu!.gpuGroupId + ); + + return ( + + {group ? ( + + ) : ( + + )} + + ); + } + return ""; +}; + +export const renderResourceType = ( + resource: Resource, + gpuGroups: GpuGroupRead[], + t: TFunction<"translation", undefined> +) => { + if (resource.type === "vm" && (resource as Vm).gpu) { + const group = gpuGroups?.find( + (x) => x.id === (resource as Vm).gpu!.gpuGroupId + ); + + return ( + + + {group ? ( + + ) : ( + + )} + + ); + } + + if (resource.type === "vm") { + return ( + + + + ); + } + + if (resource.type === "deployment") { + return ( + + + {(resource as Deployment).private === true && ( + + )} + + ); + } +}; + +export const renderResourceStatus = ( + row: Resource, + t: TFunction<"translation", undefined> +) => { + const color: ThemeColor = + (row.status === "resourceError" && "error") || + (row.status === "resourceUnknown" && "error") || + (row.status === "resourceMountFailed" && "error") || + (row.status === "resourceImagePullFailed" && "error") || + (row.status === "resourceCrashLoop" && "error") || + (row.status === "resourceStopped" && "warning") || + (row.status === "resourceRunning" && "success") || + "info"; + + const statusMessage = sentenceCase(t(row.status).replace("resource", "")); + + return ( + + ); +}; + +export const renderStatusCode = (row: Resource) => { + if (!(row.type === "deployment" && (row as Deployment).pingResult)) + return <>; + + const codeType = parseInt( + (row as Deployment).pingResult!.toString().charAt(0) + ); + + let color: ThemeColor = "info"; + if (codeType === 2 || codeType === 3) { + color = "success"; + } else if (codeType === 4 || codeType === 5) { + color = "error"; + } + + return ( + + ); +}; + +export const renderZone = (row: Resource, zones: ZoneRead[]) => { + if (!row.zone || !zones) { + return <>; + } + + const zone = zones.find( + (zone) => zone.name === row.zone && zone.capabilities.includes(row.type) + ); + + return ( + + ); +}; + +export const renderShared = ( + row: Resource, + t: TFunction<"translation", undefined> +) => { + if (row?.teams?.length === 0) return <>; + + return ( + + ); +}; + +export function isOlderThanThreeMonths(accessedAt: string | undefined) { + if (!accessedAt) return false; + + const accessedDate = new Date(accessedAt); + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + + return accessedDate < threeMonthsAgo; +} + +export const renderStale = ( + row: Resource, + t: TFunction<"translation", undefined> +) => { + const warningDaysBeforeStale = 30; + const daysLeftUntilStale = getDaysLeftUntilStale(row?.accessedAt); + const stale = + typeof daysLeftUntilStale === "number" + ? daysLeftUntilStale <= 0 + : isOlderThanThreeMonths(row?.accessedAt); + + if ( + row.neverStale || + (!stale && + (daysLeftUntilStale === false || + (daysLeftUntilStale as number) > warningDaysBeforeStale)) + ) + return <>; + + return ( + + ); +}; diff --git a/src/contexts/AdminResourceContext.tsx b/src/contexts/AdminResourceContext.tsx new file mode 100644 index 00000000..9062d55c --- /dev/null +++ b/src/contexts/AdminResourceContext.tsx @@ -0,0 +1,672 @@ +import { + createContext, + Dispatch, + SetStateAction, + useEffect, + useState, +} from "react"; +import useInterval from "../hooks/useInterval"; +import { + DeploymentRead, + GpuGroupRead, + GpuLeaseRead, + HostVerboseRead, + JobRead, + SystemCapacities, + TeamRead, + UserRead, + VmRead, +} from "@kthcloud/go-deploy-types/types/v2/body"; +import useFilterableResourceState, { + DEFAULT_PAGESIZE, +} from "../hooks/useFilterableResourceState"; +import { useKeycloak } from "@react-keycloak/web"; +import { getUsers } from "../api/deploy/users"; +import { errorHandler } from "../utils/errorHandler"; +import { enqueueSnackbar } from "notistack"; +import { useTranslation } from "react-i18next"; +import { listVMs } from "../api/deploy/vms"; +import { getDeployments } from "../api/deploy/deployments"; +import { listGpuLeases } from "../api/deploy/gpuLeases"; +import { listGpuGroups } from "../api/deploy/gpuGroups"; +import { getTeams } from "../api/deploy/teams"; +import { getJobs } from "../api/deploy/jobs"; +import useResource from "../hooks/useResource"; +import { TFunction } from "i18next"; +import { getHostsVerbose } from "../api/deploy/hosts"; +import { getSystemCapacities } from "../api/deploy/systemCapacities"; + +type AdminResourceContextType = { + fetchingEnabled: boolean; + setEnableFetching: Dispatch>; + lastRefresh: number; + lastRefreshRtt: number; + loading: boolean; + refetch: () => void; + // Users + users: UserRead[] | undefined; + usersFilter: string | undefined; + setUsersFilter: Dispatch>; + filteredUsers: UserRead[] | undefined; + usersPage: number; + setUsersPage: Dispatch>; + usersPageSize: number; + setUsersPageSize: Dispatch>; + + // Teams + teams: TeamRead[] | undefined; + teamsFilter: string | undefined; + setTeamsFilter: Dispatch>; + filteredTeams: TeamRead[] | undefined; + teamsPage: number; + setTeamsPage: Dispatch>; + teamsPageSize: number; + setTeamsPageSize: Dispatch>; + + // Deployments + deployments: DeploymentRead[] | undefined; + deploymentsFilter: string | undefined; + setDeploymentsFilter: Dispatch>; + filteredDeployments: DeploymentRead[] | undefined; + deploymentsPage: number; + setDeploymentsPage: Dispatch>; + deploymentsPageSize: number; + setDeploymentsPageSize: Dispatch>; + + // Vms + vms: VmRead[] | undefined; + vmsFilter: string | undefined; + setVmsFilter: Dispatch>; + filteredVms: VmRead[] | undefined; + vmsPage: number; + setVmsPage: Dispatch>; + vmsPageSize: number; + setVmsPageSize: Dispatch>; + + // GpuLeases + gpuLeases: GpuLeaseRead[] | undefined; + gpuLeasesFilter: string | undefined; + setGpuLeasesFilter: Dispatch>; + filteredGpuLeases: GpuLeaseRead[] | undefined; + gpuLeasesPage: number; + setGpuLeasesPage: Dispatch>; + gpuLeasesPageSize: number; + setGpuLeasesPageSize: Dispatch>; + + // GpuGroups + gpuGroups: GpuGroupRead[] | undefined; + gpuGroupsFilter: string | undefined; + setGpuGroupsFilter: Dispatch>; + filteredGpuGroups: GpuGroupRead[] | undefined; + gpuGroupsPage: number; + setGpuGroupsPage: Dispatch>; + gpuGroupsPageSize: number; + setGpuGroupsPageSize: Dispatch>; + + // Jobs + jobs: JobRead[] | undefined; + jobsFilter: string | undefined; + setJobsFilter: Dispatch>; + filteredJobs: JobRead[] | undefined; + jobsPage: number; + setJobsPage: Dispatch>; + jobsPageSize: number; + setJobsPageSize: Dispatch>; + + // Hosts + hosts: HostVerboseRead[] | undefined; + + // SystemCapacities + systemCapacities: SystemCapacities | undefined; +}; + +const initialState: AdminResourceContextType = { + fetchingEnabled: false, + setEnableFetching: () => {}, + lastRefresh: 0, + lastRefreshRtt: 0, + loading: false, + refetch: () => {}, + // Users + users: undefined, + usersFilter: undefined, + setUsersFilter: () => {}, + filteredUsers: undefined, + usersPage: 0, + setUsersPage: () => {}, + usersPageSize: DEFAULT_PAGESIZE, + setUsersPageSize: () => {}, + + // Teams + teams: undefined, + teamsFilter: undefined, + setTeamsFilter: () => {}, + filteredTeams: undefined, + teamsPage: 0, + setTeamsPage: () => {}, + teamsPageSize: DEFAULT_PAGESIZE, + setTeamsPageSize: () => {}, + + // Deployments + deployments: undefined, + deploymentsFilter: undefined, + setDeploymentsFilter: () => {}, + filteredDeployments: undefined, + deploymentsPage: 0, + setDeploymentsPage: () => {}, + deploymentsPageSize: DEFAULT_PAGESIZE, + setDeploymentsPageSize: () => {}, + + // Vms + vms: undefined, + vmsFilter: undefined, + setVmsFilter: () => {}, + filteredVms: undefined, + vmsPage: 0, + setVmsPage: () => {}, + vmsPageSize: DEFAULT_PAGESIZE, + setVmsPageSize: () => {}, + + // GpuLeases + gpuLeases: undefined, + gpuLeasesFilter: undefined, + setGpuLeasesFilter: () => {}, + filteredGpuLeases: undefined, + gpuLeasesPage: 0, + setGpuLeasesPage: () => {}, + gpuLeasesPageSize: DEFAULT_PAGESIZE, + setGpuLeasesPageSize: () => {}, + + // GpuGroups + gpuGroups: undefined, + gpuGroupsFilter: undefined, + setGpuGroupsFilter: () => {}, + filteredGpuGroups: undefined, + gpuGroupsPage: 0, + setGpuGroupsPage: () => {}, + gpuGroupsPageSize: DEFAULT_PAGESIZE, + setGpuGroupsPageSize: () => {}, + + // Jobs + jobs: undefined, + jobsFilter: undefined, + setJobsFilter: () => {}, + filteredJobs: undefined, + jobsPage: 0, + setJobsPage: () => {}, + jobsPageSize: DEFAULT_PAGESIZE, + setJobsPageSize: () => {}, + + // Hosts + hosts: undefined, + + // SystemCapacities + systemCapacities: undefined, +}; + +export const AdminResourceContext = createContext(initialState); + +export const AdminResourceContextProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const { + items: users, + setItems: setUsers, + filter: usersFilter, + setFilter: setUsersFilter, + filteredItems: filteredUsers, + page: usersPage, + setPage: setUsersPage, + pageSize: usersPageSize, + setPageSize: setUsersPageSize, + } = useFilterableResourceState(undefined); + const { + items: teams, + setItems: setTeams, + filter: teamsFilter, + setFilter: setTeamsFilter, + filteredItems: filteredTeams, + page: teamsPage, + setPage: setTeamsPage, + pageSize: teamsPageSize, + setPageSize: setTeamsPageSize, + } = useFilterableResourceState(undefined); + const { + items: deployments, + setItems: setDeployments, + filter: deploymentsFilter, + setFilter: setDeploymentsFilter, + filteredItems: filteredDeployments, + page: deploymentsPage, + setPage: setDeploymentsPage, + pageSize: deploymentsPageSize, + setPageSize: setDeploymentsPageSize, + } = useFilterableResourceState(undefined); + const { + items: vms, + setItems: setVms, + filter: vmsFilter, + setFilter: setVmsFilter, + filteredItems: filteredVms, + page: vmsPage, + setPage: setVmsPage, + pageSize: vmsPageSize, + setPageSize: setVmsPageSize, + } = useFilterableResourceState(undefined); + const { + items: gpuLeases, + setItems: setGpuLeases, + filter: gpuLeasesFilter, + setFilter: setGpuLeasesFilter, + filteredItems: filteredGpuLeases, + page: gpuLeasesPage, + setPage: setGpuLeasesPage, + pageSize: gpuLeasesPageSize, + setPageSize: setGpuLeasesPageSize, + } = useFilterableResourceState(undefined); + const { + items: gpuGroups, + setItems: setGpuGroups, + filter: gpuGroupsFilter, + setFilter: setGpuGroupsFilter, + filteredItems: filteredGpuGroups, + page: gpuGroupsPage, + setPage: setGpuGroupsPage, + pageSize: gpuGroupsPageSize, + setPageSize: setGpuGroupsPageSize, + } = useFilterableResourceState(undefined); + const { + items: jobs, + setItems: setJobs, + filter: jobsFilter, + setFilter: setJobsFilter, + filteredItems: filteredJobs, + page: jobsPage, + setPage: setJobsPage, + pageSize: jobsPageSize, + setPageSize: setJobsPageSize, + } = useFilterableResourceState(undefined); + + const [hosts, setHosts] = useState(undefined); + + const [systemCapacities, setSystemCapacities] = useState< + SystemCapacities | undefined + >(undefined); + + const [lastRefreshRtt, setLastRefreshRtt] = useState(0); + const [lastRefresh, setLastRefresh] = useState(0); + const [loading, setLoading] = useState(false); + + const { t } = useTranslation(); + const { keycloak, initialized } = useKeycloak(); + const { user } = useResource(); + + const [fetchingEnabled, setEnableFetching] = useState(false); + + const getResources = () => { + if (!loading) { + setLoading(true); + fetchResources( + keycloak, + initialized, + t, + setLastRefresh, + setLastRefreshRtt, + usersPage, + usersPageSize, + setUsers, + teamsPage, + teamsPageSize, + setTeams, + deploymentsPage, + deploymentsPageSize, + setDeployments, + vmsPage, + vmsPageSize, + setVms, + gpuLeasesPage, + gpuLeasesPageSize, + setGpuLeases, + gpuGroupsPage, + gpuGroupsPageSize, + setGpuGroups, + jobsPage, + jobsPageSize, + setJobs, + setHosts, + setSystemCapacities + ).finally(() => setLoading(false)); + } + }; + + useEffect(() => { + if ( + initialized && + keycloak.authenticated && + user && + user.admin && + fetchingEnabled && + users === undefined + ) { + getResources(); + } + }, [keycloak, initialized, user]); + + useInterval(() => { + if ( + initialized && + keycloak.authenticated && + user && + user.admin && + fetchingEnabled + ) { + getResources(); + } + }, 60000); + + return ( + { + if (initialized && keycloak.authenticated && user && user.admin) { + getResources(); + if (!fetchingEnabled) { + setEnableFetching(true); + } + } + }, + + // Users + users, + usersFilter, + setUsersFilter, + filteredUsers, + usersPage, + setUsersPage, + usersPageSize, + setUsersPageSize, + + // Teams + teams, + teamsFilter, + setTeamsFilter, + filteredTeams, + teamsPage, + setTeamsPage, + teamsPageSize, + setTeamsPageSize, + + // Deployments + deployments, + deploymentsFilter, + setDeploymentsFilter, + filteredDeployments, + deploymentsPage, + setDeploymentsPage, + deploymentsPageSize, + setDeploymentsPageSize, + + // Vms + vms, + vmsFilter, + setVmsFilter, + filteredVms, + vmsPage, + setVmsPage, + vmsPageSize, + setVmsPageSize, + + // GpuLeases + gpuLeases, + gpuLeasesFilter, + setGpuLeasesFilter, + filteredGpuLeases, + gpuLeasesPage, + setGpuLeasesPage, + gpuLeasesPageSize, + setGpuLeasesPageSize, + + // GpuGroups + gpuGroups, + gpuGroupsFilter, + setGpuGroupsFilter, + filteredGpuGroups, + gpuGroupsPage, + setGpuGroupsPage, + gpuGroupsPageSize, + setGpuGroupsPageSize, + + // Jobs + jobs, + jobsFilter, + setJobsFilter, + filteredJobs, + jobsPage, + setJobsPage, + jobsPageSize, + setJobsPageSize, + + // Hosts + hosts, + + // SystemCapacities + systemCapacities, + }} + > + {children} + + ); +}; + +async function fetchResources( + keycloak: any, + initialized: boolean, + t: TFunction<"translation", undefined>, + setLastRefresh: Dispatch>, + setLastRefreshRtt: Dispatch>, + + // Users + __usersPage: number, + __usersPageSize: number, + setUsers: Dispatch>, + + // Teams + __teamsPage: number, + __teamsPageSize: number, + setTeams: Dispatch>, + + // Deployments + __deploymentsPage: number, + __deploymentsPageSize: number, + setDeployments: Dispatch>, + + // Vms + __vmsPage: number, + __vmsPageSize: number, + setVms: Dispatch>, + + // GpuLeases + __gpuLeases: number, + __gpuLeasesPage: number, + setGpuLeases: Dispatch>, + + // GpuGroups + __gpuGroups: number, + __gpuGroupsPageSize: number, + setGpuGroups: Dispatch>, + + // Jobs + __jobsPage: number, + __jobsPageSize: number, + setJobs: Dispatch>, + + // Hosts + setHosts: Dispatch>, + + // SystemCapacities + setSystemCapacities: Dispatch> +) { + if (!(initialized && keycloak.authenticated && keycloak.token)) return; + const rtts: Record = {}; + const promises = [ + async () => { + try { + const start = performance.now(); + const response = await getUsers(keycloak.token!, { + all: true, + }); + rtts[0] = { start, end: performance.now() }; + setUsers(response); + } catch (error: any) { + errorHandler(error).forEach((e) => + enqueueSnackbar(t("error-could-not-fetch-users") + ": " + e, { + variant: "error", + }) + ); + } + }, + + async () => { + try { + const start = performance.now(); + const response = await listVMs(keycloak.token!, true); + rtts[1] = { start, end: performance.now() }; + setVms(response); + } catch (error: any) { + errorHandler(error).forEach((e) => + enqueueSnackbar(t("error-could-not-fetch-vms") + ": " + e, { + variant: "error", + }) + ); + } + }, + async () => { + try { + const start = performance.now(); + const response = await getDeployments(keycloak.token!, true); + rtts[2] = { start, end: performance.now() }; + setDeployments(response); + } catch (error: any) { + errorHandler(error).forEach((e) => + enqueueSnackbar(t("error-could-not-fetch-deployments") + ": " + e, { + variant: "error", + }) + ); + } + }, + async () => { + try { + const start = performance.now(); + const response = await listGpuLeases(keycloak.token!, undefined, true); + rtts[3] = { start, end: performance.now() }; + setGpuLeases(response); + } catch (error: any) { + errorHandler(error).forEach((e) => + enqueueSnackbar(t("error-could-not-fetch-gpus") + ": " + e, { + variant: "error", + }) + ); + } + }, + async () => { + try { + const start = performance.now(); + const response = await listGpuGroups(keycloak.token!, undefined); + rtts[4] = { start, end: performance.now() }; + setGpuGroups(response); + } catch (error: any) { + errorHandler(error).forEach((e) => + enqueueSnackbar(t("error-could-not-fetch-gpus") + ": " + e, { + variant: "error", + }) + ); + } + }, + async () => { + try { + const start = performance.now(); + const response = await getTeams(keycloak.token!, true); + rtts[5] = { start, end: performance.now() }; + setTeams(response); + } catch (error: any) { + errorHandler(error).forEach((e) => + enqueueSnackbar(t("error-could-not-fetch-teams") + ": " + e, { + variant: "error", + }) + ); + } + }, + + async () => { + try { + const start = performance.now(); + const response = await getJobs( + keycloak.token!, + undefined, + undefined, + true + ); + rtts[6] = { start, end: performance.now() }; + setJobs(response); + } catch (error: any) { + errorHandler(error).forEach((e) => + enqueueSnackbar(t("error-could-not-fetch-jobs") + ": " + e, { + variant: "error", + }) + ); + } + }, + + async () => { + try { + const start = performance.now(); + const response = await getHostsVerbose(keycloak.token!); + rtts[7] = { start, end: performance.now() }; + setHosts(response); + } catch (error: any) { + errorHandler(error).forEach((e) => + enqueueSnackbar(t("error-could-not-fetch-hosts") + ": " + e, { + variant: "error", + }) + ); + } + }, + + async () => { + try { + const start = performance.now(); + const response = await getSystemCapacities(keycloak.token!); + rtts[8] = { start, end: performance.now() }; + if (response) setSystemCapacities(response); + } catch (error: any) { + errorHandler(error).forEach((e) => + enqueueSnackbar( + t("error-could-not-fetch-system-capacities") + ": " + e, + { + variant: "error", + } + ) + ); + } + }, + ]; + + await Promise.all(promises.map((p) => p())); + + const rttValues = Object.values(rtts).map(({ start, end }) => end - start); + const averageRtt = + rttValues.length > 0 + ? rttValues.reduce((sum, rtt) => sum + rtt, 0) / rttValues.length + : 0; + + // end timer and set last refresh, show in ms + setLastRefresh(new Date().getTime()); + setLastRefreshRtt(averageRtt); +} diff --git a/src/hooks/useAdmin.ts b/src/hooks/useAdmin.ts new file mode 100644 index 00000000..f3f74b9a --- /dev/null +++ b/src/hooks/useAdmin.ts @@ -0,0 +1,5 @@ +import { useContext } from "react"; +import { AdminResourceContext } from "../contexts/AdminResourceContext"; +const useAdmin = () => useContext(AdminResourceContext); + +export default useAdmin; diff --git a/src/hooks/useFilterableResourceState.ts b/src/hooks/useFilterableResourceState.ts new file mode 100644 index 00000000..4e9e6ffb --- /dev/null +++ b/src/hooks/useFilterableResourceState.ts @@ -0,0 +1,47 @@ +import { useState, useMemo, Dispatch, SetStateAction, useEffect } from "react"; + +export const DEFAULT_PAGESIZE = 10; + +export default function useFilterableResourceState( + defaultState: T[] | undefined +): { + items: T[] | undefined; + setItems: Dispatch>; + filter: string | undefined; + setFilter: Dispatch>; + filteredItems: T[] | undefined; + page: number; + setPage: Dispatch>; + pageSize: number; + setPageSize: Dispatch>; +} { + const [items, setItems] = useState(defaultState); + const [filter, setFilter] = useState(undefined); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(DEFAULT_PAGESIZE); + + const filteredItems = useMemo(() => { + if (filter === undefined) return undefined; + return items?.filter((item) => + JSON.stringify(item).toLowerCase().includes(filter.toLowerCase()) + ); + }, [items, filter]); + + useEffect(() => { + if (page !== 0) { + setPage(0); + } + }, [filter]); + + return { + items, + setItems, + filter, + setFilter, + filteredItems, + page, + setPage, + pageSize, + setPageSize, + }; +} diff --git a/src/keycloak.ts b/src/keycloak.ts index 260f9c25..3b88376c 100644 --- a/src/keycloak.ts +++ b/src/keycloak.ts @@ -1,9 +1,9 @@ import Keycloak from "keycloak-js"; const config = { - url: "https://iam.cloud.cbh.kth.se", - realm: "cloud", - clientId: "landing", + url: import.meta.env.VITE_KEYCLOAK_URL, + realm: import.meta.env.VITE_KEYCLOAK_REALM, + clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID, }; const keycloak = new Keycloak(config); diff --git a/src/layouts/dashboard/Menu.tsx b/src/layouts/dashboard/Menu.tsx index 7b9c8465..027e4eb2 100644 --- a/src/layouts/dashboard/Menu.tsx +++ b/src/layouts/dashboard/Menu.tsx @@ -153,7 +153,7 @@ export default function Menu() { {t("menu-status")} - - {t("menu-admin-panel")} - - {t("menu-cloudstack")} + {t("menu-admin-panel")} {t("menu-rancher")} {t("menu-keycloak")} diff --git a/src/locales/en.json b/src/locales/en.json index 43b5fd0e..f53eabd4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -538,6 +538,27 @@ "admin-visibility-auth": "Private (auth proxy)", "visibility-public-tooltip": "Your deployment is publicly accessible on the internet.", "visibility-private-tooltip": "Your deployment is private and not reachable on the internet. Only internal traffic is allowed in (traffic between deployments).", - "visibility-auth-tooltip": "Your deployment is private and reachable on the internet through an authentication proxy, this limits the allowed traffic." + "visibility-auth-tooltip": "Your deployment is private and reachable on the internet through an authentication proxy, this limits the allowed traffic.", + "stale": "Stale", + "stale-description": "This resource hasn't been accessed by its owner for more than or equal to 3 months.", + "stale-and-disabled-description": "This resource has been disabled due to inactivity. Please contact the resource owner to log in and restore access.", + "stale-and-not-disabled-description": "Due to prolonged inactivity, this resource will be disabled soon.", + "stale-soon": "Stale soon", + "stale-soon-description": "This resource will become stale soon due to inactivity from the owner", + "stale-in": "Stale in", + "days-left": "days left", + "never-stale-option-header": "Always Active Option", + "never-stale-option-header-subheader": "Control whether this resource should remain active despite inactivity.", + "never-stale": "Always Active", + "error-could-not-fetch-hosts": "Could not fetch hosts", + "error-could-not-fetch-system-capacities": "Could not fetch system capacities", + "unschedulable": "Unschedulable", + "schedulable": "Schedulable", + "schedulable-description": "The last time this node reported its status, it was schedulable in the Kubernetes cluster", + "unschedulable-description": "The last time this node reported its status, it was unschedulable in the Kubernetes cluster", + "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" } } diff --git a/src/locales/se.json b/src/locales/se.json index f1f4e101..c6a7c2f1 100644 --- a/src/locales/se.json +++ b/src/locales/se.json @@ -464,6 +464,8 @@ "joinedAt": "Gick med", "addedAt": "Lades till", "jobRestarted": "Jobb startat om", + "maia": "MAIA", + "maia-text": "BLA BLA BLA", "funding-provided-by": "kthcloud samarbetar med och stöttas av många organisationer. Finansiellt stöd har vi fått från ", "the": " ", "program-and-the": "programmet och ", @@ -537,6 +539,27 @@ "admin-visibility-auth": "Privat (auth proxy)", "visibility-public-tooltip": "Din deployment är publikt åtkomlig på internet.", "visibility-private-tooltip": "Din deployment är privat och inte åtkomlig på internet. Enbart intern trafik mellan appar är tillåten.", - "visibility-auth-tooltip": "Din deployment är privat och åtkomlig på internet via en autentiserings-proxy, detta begränsar tillåten trafik." + "visibility-auth-tooltip": "Din deployment är privat och åtkomlig på internet via en autentiserings-proxy, detta begränsar tillåten trafik.", + "stale": "Inaktiv", + "stale-description": "Denna resurs har inte åtkommits av dess ägare under mer än eller lika med 3 månader.", + "stale-and-disabled-description": "Denna resurs har inaktiverats på grund av inaktivitet. Kontakta resursägaren för att logga in och återställa åtkomsten.", + "stale-and-not-disabled-description": "På grund av långvarig inaktivitet kommer denna resurs att inaktiveras inom en snar framtid.", + "stale-soon": "Snart inaktiv", + "stale-soon-description": "Denna resurs kommer snart att bli avaktiverad på grund av ägarens inaktivitet", + "stale-in": "Inaktiv om", + "days-left": "dagar kvar", + "never-stale-option-header": "Aldrig inaktiv-alternativ", + "never-stale-option-header-subheader": "Styr om denna resurs ska förbli aktiv trots inaktivitet.", + "never-stale": "Alltid aktiv", + "error-could-not-fetch-hosts": "Kunde inte hämta värdmaskiner", + "error-could-not-fetch-system-capacities": "Kunde inte hämta kapaciteten hos systemet", + "unschedulable": "Oschemaläggningsbar", + "schedulable": "Schemaläggningsbar", + "schedulable-description": "Senast denna nod rapporterade sin status var den schemaläggningsbar i Kubernetes-klustret", + "unschedulable-description": "Senast denna nod rapporterade sin status var den oschemaläggningsbar i Kubernetes-klustret", + "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" } } diff --git a/src/pages/admin/Admin.tsx b/src/pages/admin/Admin.tsx index 1faf3a42..b1cd0fed 100644 --- a/src/pages/admin/Admin.tsx +++ b/src/pages/admin/Admin.tsx @@ -853,7 +853,7 @@ export const Admin = () => { {t("admin-edit-permissions-in") + " "} diff --git a/src/pages/admin/AdminV2.tsx b/src/pages/admin/AdminV2.tsx new file mode 100644 index 00000000..a4b85d88 --- /dev/null +++ b/src/pages/admin/AdminV2.tsx @@ -0,0 +1,600 @@ +import { + Card, + Container, + LinearProgress, + Link, + Stack, + Tab, + Tabs, + Tooltip, + Typography, +} from "@mui/material"; + +import { useTranslation } from "react-i18next"; +import useResource from "../../hooks/useResource"; +import { useNavigate, useParams } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { enqueueSnackbar } from "notistack"; +import useAdmin from "../../hooks/useAdmin"; +import LoadingPage from "../../components/LoadingPage"; +import Page from "../../components/Page"; +import ResourceTab from "../../components/admin/ResourceTab"; +import { + DeploymentRead, + GpuGroupRead, + GpuLeaseRead, + TeamRead, + UserRead, + VmRead, +} from "@kthcloud/go-deploy-types/types/v2/body"; +import { + renderResourceStatus, + renderResourceWithGPU, + renderShared, + renderStale, + renderStatusCode, +} from "../../components/render/Resource"; +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 { deleteVM } from "../../api/deploy/vms"; +import { deleteGpuLease } from "../../api/deploy/gpuLeases"; +import { deleteTeam } from "../../api/deploy/teams"; +import TimeLeft from "../../components/admin/TimeLeft"; + +export default function AdminV2() { + const { tab: initialTab } = useParams(); + const { t } = useTranslation(); + const { keycloak } = useKeycloak(); + const { user, setImpersonatingDeployment, setImpersonatingVm } = + useResource(); + const { + fetchingEnabled, + setEnableFetching, + + // Users + users, + usersFilter, + setUsersFilter, + filteredUsers, + usersPage, + setUsersPage, + usersPageSize, + setUsersPageSize, + + // Teams + teams, + teamsFilter, + setTeamsFilter, + filteredTeams, + teamsPage, + setTeamsPage, + teamsPageSize, + setTeamsPageSize, + + // Deployments + deployments, + deploymentsFilter, + setDeploymentsFilter, + filteredDeployments, + deploymentsPage, + setDeploymentsPage, + deploymentsPageSize, + setDeploymentsPageSize, + + // Vms + vms, + vmsFilter, + setVmsFilter, + filteredVms, + vmsPage, + setVmsPage, + vmsPageSize, + setVmsPageSize, + + // GpuLeases + gpuLeases, + gpuLeasesFilter, + setGpuLeasesFilter, + filteredGpuLeases, + gpuLeasesPage, + setGpuLeasesPage, + gpuLeasesPageSize, + setGpuLeasesPageSize, + + // GpuGroups + gpuGroups, + gpuGroupsFilter, + setGpuGroupsFilter, + filteredGpuGroups, + gpuGroupsPage, + setGpuGroupsPage, + gpuGroupsPageSize, + setGpuGroupsPageSize, + } = useAdmin(); + const navigate = useNavigate(); + + const [activeTab, setActiveTab] = useState(0); + + const handleChangeTab = (_: any, newTab: number) => { + setActiveTab(newTab); + }; + + const impersonate = (resourceType: string, id: Uuid) => { + if (resourceType === "vm") { + setImpersonatingVm(id); + navigate("/edit/vm/" + id); + } else if (resourceType === "deployment") { + setImpersonatingDeployment(id); + navigate("/edit/deployment/" + id); + } + }; + + const resourceConfig = [ + { + label: "Deployments", + columns: [ + { id: "id", label: "ID" }, + { id: "name", label: "Deployment Name" }, + { + id: "ownerId", + label: "Owner", + renderFunc: (userId: string) => { + return users?.find((user) => user.id === userId)?.username; + }, + }, + { id: "zone", label: "Zone" }, + { id: "image", label: "Image", or: "Custom deployment" }, + { + id: "*", + label: "Status", + renderFunc: (deployment: DeploymentRead) => { + return ( + + {renderResourceStatus(deployment as Resource, t)} + {renderStatusCode(deployment as Resource)} + {renderShared(deployment as Resource, t)} + {renderStale(deployment as Resource, t)} + + ); + }, + }, + { id: "visibility", label: "Visibility" }, + { + id: "url", + label: t("visit-page"), + renderFunc: (value: any) => { + if (!value) return ""; + return ( + + {t("visit-page")} + + ); + }, + }, + ], + actions: [ + { + label: t("button-edit"), + onClick: (value: DeploymentRead) => { + impersonate("deployment", value.id); + }, + }, + { + label: t("button-delete"), + onClick: (deployment: DeploymentRead) => { + if (keycloak.token) deleteDeployment(deployment.id, keycloak.token); + }, + withConfirm: true, + }, + ], + }, + { + label: "VMs", + columns: [ + { id: "id", label: "ID" }, + { id: "name", label: "Name" }, + { + id: "ownerId", + label: "Owner", + renderFunc: (userId: string) => { + return users?.find((user) => user.id === userId)?.username; + }, + }, + { + id: "*", + label: "GPUs", + renderFunc: (vm: VmRead) => + renderResourceWithGPU(vm as Resource, gpuGroups!), + }, + { id: "zone", label: "Zone" }, + { id: "host", label: "Host" }, + { + id: "*", + label: "Status", + renderFunc: (vm: VmRead) => { + return ( + + {renderResourceStatus(vm as Resource, t)} + {renderShared(vm as Resource, t)} + {renderStale(vm as Resource, t)} + + ); + }, + }, + ], + actions: [ + { + label: t("button-edit"), + onClick: (value: VmRead) => { + impersonate("vm", value.id); + }, + }, + { + label: t("button-delete"), + onClick: (vm: VmRead) => { + if (keycloak.token) deleteVM(keycloak.token, vm.id); + }, + withConfirm: true, + }, + ], + }, + { + label: "Gpu Leases", + columns: [ + { id: "id", label: "ID" }, + { + id: "userId", + label: "User", + renderFunc: (userId: string) => { + return users?.find((user) => user.id === userId)?.username; + }, + }, + { + id: "gpuGroupId", + label: "GPU", + renderFunc: (gpuGroupId: string) => { + return gpuGroups?.find((gpuGroup) => gpuGroup.id === gpuGroupId) + ?.displayName; + }, + }, + { + id: "queuePosition", + label: "QueuePosition", + }, + { id: "active", label: "Active" }, + { id: "vmId", label: "VmId", or: "N/A" }, + { id: "leaseDuration", label: "Duration" }, + { + id: "expiresAt", + label: "Expiry", + renderFunc: (expiresAt: string) => ( + + ), + }, + ], + actions: [ + { + label: t("button-delete"), + onClick: (gpuLease: GpuLeaseRead) => { + if (keycloak.token) deleteGpuLease(keycloak.token, gpuLease.id); + }, + withConfirm: true, + }, + ], + }, + { + label: "GPU Groups", + columns: [ + { id: "id", label: "ID" }, + { id: "name", label: "Name" }, + { id: "displayName", label: "Display Name" }, + { id: "zone", label: "Zone" }, + { id: "vendor", label: "Vendor" }, + { + id: "*", + label: "Available", + renderFunc: (gpuGroup: GpuGroupRead) => + `${gpuGroup.available} / ${gpuGroup.total}`, + }, + ], + actions: [ + { + label: "Leases", + onClick: (gpuGroup: GpuGroupRead) => { + setGpuLeasesFilter(gpuGroup.id); + setActiveTab(2); + }, + }, + ], + }, + { + label: "Users", + columns: [ + { id: "id", label: "ID" }, + { id: "username", label: "Username" }, + { id: "email", label: "Email" }, + { id: "role.name", label: "Role" }, + { id: "admin", label: "Admin" }, + { + id: "*", + label: "Usage", + renderFunc: (user: UserRead) => { + if (!user || !user.usage || !user.quota) return "N/A"; + + const calculatePercentage = (used: number, total: number) => + total ? ((used / total) * 100).toFixed(1) : "0.0"; + + const { cpu, ram, disk } = { + cpu: calculatePercentage( + user.usage.cpuCores, + user.quota.cpuCores + ), + ram: calculatePercentage(user.usage.ram, user.quota.ram), + disk: calculatePercentage( + user.usage.diskSize, + user.quota.diskSize + ), + }; + + return ( +
+ CPU + + + + + RAM + + + + + Disk + + + +
+ ); + }, + }, + ], + actions: [ + { + label: "Deployments", + onClick: (user: UserRead) => { + setDeploymentsFilter(user.id); + setActiveTab(0); + }, + }, + { + label: "VMs", + onClick: (user: UserRead) => { + setVmsFilter(user.id); + setActiveTab(1); + }, + }, + { + label: "Teams", + onClick: (user: UserRead) => { + setTeamsFilter(user.id); + setActiveTab(5); + }, + }, + { + label: "Open Keycloak", + onClick: (user: UserRead) => { + const userUrl = `${import.meta.env.VITE_KEYCLOAK_URL}/admin/master/console/#/${import.meta.env.VITE_KEYCLOAK_REALM}/users/${user.id}/settings`; + window.open(userUrl, "_blank"); + }, + }, + ], + }, + { + label: "Teams", + columns: [ + { id: "id", label: "ID" }, + { id: "name", label: "Team Name" }, + { + id: "members", + label: "Members", + renderFunc: (members: { username: string }[]) => + members.map((member) => member.username).join(", "), + }, + { + id: "resources", + label: "Resources", + renderFunc: (resources: { name: string; type: string }[]) => { + const deploymentCount = resources.filter( + (resource) => resource.type === "deployment" + ).length; + const vmCount = resources.filter( + (resource) => resource.type === "vm" + ).length; + + return `${deploymentCount} Depls, ${vmCount} VMs`; + }, + }, + ], + actions: [ + { + label: t("button-delete"), + onClick: (team: TeamRead) => { + if (keycloak.token) deleteTeam(keycloak.token!, team.id); + }, + withConfirm: true, + }, + ], + }, + ]; + + const tabLookup = resourceConfig.reduce>( + (acc, obj, index) => { + acc[obj.label.toLowerCase()] = index; + return acc; + }, + {} + ); + tabLookup["hosts"] = resourceConfig.length; + useEffect(() => { + if ( + initialTab && + tabLookup[initialTab.toLowerCase()] && + tabLookup[initialTab.toLowerCase()] !== activeTab + ) { + setActiveTab(tabLookup[initialTab.toLowerCase()]); + } + }, []); + + const resourceLookup = [ + { + data: deployments, + filter: deploymentsFilter, + setFilter: setDeploymentsFilter, + filteredData: filteredDeployments, + page: deploymentsPage, + setPage: setDeploymentsPage, + pageSize: deploymentsPageSize, + setPageSize: setDeploymentsPageSize, + }, + { + data: vms, + filter: vmsFilter, + setFilter: setVmsFilter, + filteredData: filteredVms, + page: vmsPage, + setPage: setVmsPage, + pageSize: vmsPageSize, + setPageSize: setVmsPageSize, + }, + { + data: gpuLeases, + filter: gpuLeasesFilter, + setFilter: setGpuLeasesFilter, + filteredData: filteredGpuLeases, + page: gpuLeasesPage, + setPage: setGpuLeasesPage, + pageSize: gpuLeasesPageSize, + setPageSize: setGpuLeasesPageSize, + }, + { + data: gpuGroups, + filter: gpuGroupsFilter, + setFilter: setGpuGroupsFilter, + filteredData: filteredGpuGroups, + page: gpuGroupsPage, + setPage: setGpuGroupsPage, + pageSize: gpuGroupsPageSize, + setPageSize: setGpuGroupsPageSize, + }, + { + data: users, + filter: usersFilter, + setFilter: setUsersFilter, + filteredData: filteredUsers, + page: usersPage, + setPage: setUsersPage, + pageSize: usersPageSize, + setPageSize: setUsersPageSize, + }, + { + data: teams, + filter: teamsFilter, + setFilter: setTeamsFilter, + filteredData: filteredTeams, + page: teamsPage, + setPage: setTeamsPage, + pageSize: teamsPageSize, + setPageSize: setTeamsPageSize, + }, + ]; + + useEffect(() => { + if (!user) return; + if (!user.admin) { + enqueueSnackbar("Cannot access admin panel: Unauthorized", { + variant: "error", + }); + navigate("/deploy"); + } + }, [user]); + + useEffect(() => { + if (!fetchingEnabled) { + setEnableFetching(true); + } + return () => { + if (fetchingEnabled) { + setEnableFetching(false); + } + }; + }, []); + + const tabs = [ + ...resourceConfig.map((config, index) => ( + + key={index} + resourceName={config.label} + data={resourceLookup[index].data} + filteredData={resourceLookup[index].filteredData} + filter={resourceLookup[index].filter} + setFilter={resourceLookup[index].setFilter} + columns={config.columns} + actions={config.actions} + page={resourceLookup[index].page} + setPage={resourceLookup[index].setPage} + pageSize={resourceLookup[index].pageSize} + setPageSize={resourceLookup[index].setPageSize} + /> + )), + , + ]; + + useEffect(() => { + const path = `/admin/${activeTab < resourceConfig.length ? resourceConfig[activeTab].label.toLowerCase() : "hosts"}`; + + if (window.location.pathname !== path) { + navigate(path); + } + }, [activeTab]); + + return ( + <> + {!user ? ( + + ) : ( + + + + + + + {t("menu-admin-panel")} + + + + {resourceConfig.map((resource, index) => ( + + ))} + + + {tabs[activeTab]} + + + + + )} + + ); +} diff --git a/src/pages/admin/index.ts b/src/pages/admin/index.ts index 1130c83d..bf3650ee 100644 --- a/src/pages/admin/index.ts +++ b/src/pages/admin/index.ts @@ -1 +1 @@ -export { Admin as default } from "./Admin"; +export { default as default } from "./AdminV2"; diff --git a/src/pages/create/CreateVm.tsx b/src/pages/create/CreateVm.tsx index 414e73dd..7a29bf1f 100644 --- a/src/pages/create/CreateVm.tsx +++ b/src/pages/create/CreateVm.tsx @@ -169,6 +169,7 @@ export default function CreateVm({ diskSize: diskSize, ram: ram, ports: [], + neverStale: false, }; // zone: selectedZone, diff --git a/src/pages/deploy/Deploy.tsx b/src/pages/deploy/Deploy.tsx index eff998c2..564aa1b9 100644 --- a/src/pages/deploy/Deploy.tsx +++ b/src/pages/deploy/Deploy.tsx @@ -46,6 +46,7 @@ import { ThemeColor } from "../../theme/types"; import { deleteVM } from "../../api/deploy/vms"; import { AlertList } from "../../components/AlertList"; import { NoWrapTable as Table } from "../../components/NoWrapTable"; +import { renderStale } from "../../components/render/Resource"; const descendingComparator = ( a: Record, @@ -542,6 +543,7 @@ export function Deploy() { {renderResourceStatus(row)} {renderStatusCode(row)} {renderShared(row)} + {renderStale(row, t)}
diff --git a/src/pages/edit/Edit.tsx b/src/pages/edit/Edit.tsx index d023d6a1..ad824890 100644 --- a/src/pages/edit/Edit.tsx +++ b/src/pages/edit/Edit.tsx @@ -48,6 +48,10 @@ import { AlertList } from "../../components/AlertList"; import { Specs } from "./Specs"; import { ReplicaStatus } from "./deployments/ReplicaStatus"; import ProxyManager from "./vms/ProxyManager"; +import { isOlderThanThreeMonths } from "../../components/render/Resource"; +import Label from "../../components/Label"; +import { getDaysLeftUntilStale } from "../../utils/staleDates"; +import NeverStaleMode from "./NeverStaleMode"; export function Edit() { const { t } = useTranslation(); @@ -192,6 +196,72 @@ export function Edit() { /> ); }; + const renderStaleResourceHeaderFullWidth = (resource: Resource) => { + const warningDaysBeforeStale = 30; + const daysLeftUntilStale = getDaysLeftUntilStale(resource?.accessedAt); + const stale = + typeof daysLeftUntilStale === "number" + ? daysLeftUntilStale <= 0 + : isOlderThanThreeMonths(resource?.accessedAt); + + if ( + resource.neverStale || + (!stale && + (daysLeftUntilStale === false || + (daysLeftUntilStale as number) > warningDaysBeforeStale)) + ) + return <>; + + // Styles for the icon and header + const boxStyles: React.CSSProperties = { + display: "flex", + alignItems: "center", + gap: "8px", + }; + + return ( + + ); + }; return ( <> @@ -330,6 +400,7 @@ export function Edit() { + {renderStaleResourceHeaderFullWidth(resource)} {resource.type === "vm" && } @@ -380,6 +451,8 @@ export function Edit() { )} + {user?.admin && } + diff --git a/src/pages/edit/NeverStaleMode.tsx b/src/pages/edit/NeverStaleMode.tsx new file mode 100644 index 00000000..7af52466 --- /dev/null +++ b/src/pages/edit/NeverStaleMode.tsx @@ -0,0 +1,90 @@ +import { + Card, + CardContent, + CardHeader, + FormControlLabel, + Switch, +} from "@mui/material"; +import { useKeycloak } from "@react-keycloak/web"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Job, Resource } from "../../types"; +import { enqueueSnackbar } from "notistack"; +import { updateDeployment } from "../../api/deploy/deployments"; +import { updateVM } from "../../api/deploy/vms"; +import useResource from "../../hooks/useResource"; +import { errorHandler } from "../../utils/errorHandler"; + +export default function NeverStaleMode({ resource }: { resource: Resource }) { + const { t } = useTranslation(); + const { keycloak, initialized } = useKeycloak(); + const { queueJob, beginFastLoad } = useResource(); + + const [neverStale, setNeverStale] = useState( + resource?.neverStale || false + ); + const [isUpdating, setIsUpdating] = useState(false); + useEffect(() => { + if (!isUpdating) setNeverStale(resource?.neverStale || false); + }, [resource, isUpdating]); + + const handleNeverStaleChange = async (neverStaleV: boolean) => { + if (!(initialized && resource && keycloak.token)) { + enqueueSnackbar(t("error-updating"), { variant: "error" }); + return; + } + + try { + let result: Job | null = null; + if (resource.type === "deployment") { + result = await updateDeployment( + resource.id, + { neverStale: neverStaleV }, + keycloak.token + ); + } else if (resource.type === "vm") { + result = await updateVM(keycloak.token, resource.id, { + //@ts-ignore not added yet + neverStale: neverStaleV, + }); + } + + if (result) { + queueJob(result); + beginFastLoad(); + enqueueSnackbar(t("saving-never-stale"), { variant: "info" }); + setNeverStale(neverStaleV); + setIsUpdating(true); + } + } catch (error: any) { + errorHandler(error).forEach((e) => + enqueueSnackbar(t("error-updating") + ": " + e, { + variant: "error", + }) + ); + } finally { + setIsUpdating(false); + } + }; + + return ( + + + + handleNeverStaleChange(e.target.checked)} + inputProps={{ "aria-label": "controlled" }} + /> + } + label={t("never-stale")} + /> + + + ); +} diff --git a/src/pages/edit/deployments/GHActions.tsx b/src/pages/edit/deployments/GHActions.tsx index eccc65db..e8925f56 100644 --- a/src/pages/edit/deployments/GHActions.tsx +++ b/src/pages/edit/deployments/GHActions.tsx @@ -13,11 +13,13 @@ import { Link, Paper, Stack, + Tab, TableBody, TableCell, TableContainer, TableHead, TableRow, + Tabs, TextareaAutosize, Typography, useTheme, @@ -34,17 +36,29 @@ type Secret = { value: string; }; +const MACOS_LINUX_INDEX: number = 0; +const WINDOWS_INDEX: number = 1; + const GHActions = ({ resource }: { resource: Deployment }) => { const { t } = useTranslation(); const { keycloak, initialized } = useKeycloak(); const [actionsFile, setActionsFile] = useState(""); const [cliCommands, setCliCommands] = useState(""); + const [cliCommandsPS, setCliCommandsPS] = useState(""); const [redacted, setRedacted] = useState(""); + const [redactedPS, setRedactedPS] = useState(""); const [secrets, setSecrets] = useState([]); const [showSecrets, setShowSecrets] = useState(false); const [showCliSecrets, setShowCliSecrets] = useState(false); const theme: CustomTheme = useTheme(); + const [tabIndex, setTabIndex] = useState(0); + + useEffect(() => { + const isWindows = navigator.platform.toLowerCase().includes("win"); + setTabIndex(isWindows ? WINDOWS_INDEX : MACOS_LINUX_INDEX); + }, []); + const loadYaml = async () => { if (!(initialized && keycloak.token)) return; try { @@ -60,13 +74,15 @@ const GHActions = ({ resource }: { resource: Deployment }) => { const commands = [ `docker login ${registry} -u ${username} -p ${password}`, - `docker build -t ${tag} .`, - `docker push ${tag}`, + `docker buildx build --platform="${import.meta.env.VITE_SERVER_PLATFORM || "linux/amd64"}" -t ${tag} --push .`, ]; // escape $ for bash const commandString = commands.join("\n").replace(/\$/g, "\\$"); + // on windows use ` to escape it in powershell + const commandStringPS = commands.join("\n").replace(/\$/g, "`$"); setCliCommands(commandString); + setCliCommandsPS(commandStringPS); setRedacted( commandString .replace(password, "********") @@ -74,6 +90,13 @@ const GHActions = ({ resource }: { resource: Deployment }) => { .replace(registry, "********") .replace(tag, "********") ); + setRedactedPS( + commandStringPS + .replace(password, "********") + .replace(username, "********") + .replace(registry, "********") + .replace(tag, "********") + ); // Get the secrets const secrets = [ @@ -119,17 +142,29 @@ const GHActions = ({ resource }: { resource: Deployment }) => { return ( <> + setTabIndex(newIndex)} + > + + + + { }} /> + - + + + + + {/* Brain block - comes second on desktop, second (below) on mobile */} + + {/* Desktop brain */} + + + + + {/* Mobile brain */} + + + + + + + + + ); +}; + +export default Maia; diff --git a/src/pages/landing/components/maia/intro.css b/src/pages/landing/components/maia/intro.css new file mode 100644 index 00000000..6c1b97d1 --- /dev/null +++ b/src/pages/landing/components/maia/intro.css @@ -0,0 +1,43 @@ +.lil-curve { + width: 64px; + text-align: center; +} +.jeremy-card { + z-index: 10; +} +.blur { + position: relative; + top: 100px; + left: -150px; + z-index: 1; + margin-top: -100px; + width: 500px; +} + +@media screen and (min-width: 900px) { + .lil-curve { + transform: translateY(-100px); + } +} +@media screen and (max-width: 900px) { + .blur { + position: relative; + top: 0px; + left: -10px; + margin: 0 auto; + margin-top: -100px; + margin-bottom: -30px; + + text-align: center; + } +} + +@media screen and (max-width: 600px) { + .blur { + top: 0px; + left: -500px; + margin-bottom: -30px; + + text-align: center; + } +} diff --git a/src/pages/profile/Profile.tsx b/src/pages/profile/Profile.tsx index 43ee6ea6..bcf5839a 100644 --- a/src/pages/profile/Profile.tsx +++ b/src/pages/profile/Profile.tsx @@ -259,7 +259,7 @@ export function Profile() {