diff --git a/src/App.tsx b/src/App.tsx index f89b837..6c1411a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,8 @@ import {ItemUsagePage} from "./pages/items/ItemUsagePage"; import {SessionsStatsPage} from "./pages/sessions/SessionsStatsPage"; import {AllCharactersPage} from "./pages/characters/AllCharactersPage"; import {AllPlayersPage} from "./pages/players/AllPlayersPage"; +import {RedirectPage} from "./pages/RedirectPage"; +import {ManageInstancePage} from "./pages/foundry/ManageInstancePage"; const router = createBrowserRouter([ { @@ -22,12 +24,13 @@ const router = createBrowserRouter([ children: [ { index: true, element: }, { path: "auth", element: }, + { path: "inactive/:instanceUrl/:other?", element: }, { path: "user", element: , children : [ { index: true, element: }, - { path: ":characterId", element: } + { path: ":characterId", element: }, ] }, { @@ -63,6 +66,13 @@ const router = createBrowserRouter([ { path: "add", element: }, { path: "usage", element: } ] + }, + { + path: "foundry", + element: , + children: [ + { path: "instances", element: }, + ] } ], } diff --git a/src/components/character/CharacterList.tsx b/src/components/character/CharacterList.tsx index bce87a1..98cba3e 100644 --- a/src/components/character/CharacterList.tsx +++ b/src/components/character/CharacterList.tsx @@ -47,8 +47,8 @@ export const CharacterList = ({ Character history - {!!activeCharacters && - activeCharacters.map((it) => ( + {!!otherCharacters && + otherCharacters.map((it) => ( ))} diff --git a/src/components/foundry/FoundryRow.tsx b/src/components/foundry/FoundryRow.tsx new file mode 100644 index 0000000..9889b6d --- /dev/null +++ b/src/components/foundry/FoundryRow.tsx @@ -0,0 +1,59 @@ +import { + Badge, Button, + GridItem, + Link, + Text +} from "@chakra-ui/react"; +import React from "react"; +import {InstanceInfo} from "../../models/foundry/InstanceInfo"; +import {useStopInstanceMutation} from "../../services/foundry"; + +interface FoundryRowProps { + instanceInfo: InstanceInfo +} + +export const FoundryRow = ({ instanceInfo }: FoundryRowProps) => { + const [stopInstance] = useStopInstanceMutation() + return ( + <> + + + {instanceInfo.id} + + + + {instanceInfo.masterName} + + + {computeTtl(instanceInfo.uptime)} + + + {instanceInfo.status === "online" && ONLINE} + {instanceInfo.status === "stopped" && STOPPED} + {instanceInfo.status !== "stopped" + && instanceInfo.status !== "online" + && {instanceInfo.status.toUpperCase()}} + + + {Math.ceil(instanceInfo.cpu * 100)}% + + + {Math.ceil(instanceInfo.memory / 1024 / 1024)} Mb + + + {Math.ceil(instanceInfo.diskSize / 1024 / 1024)} Mb + + + + + + ) +} + +function computeTtl(timestamp: number) { + const differenceInMinutes = Math.floor((new Date().getTime() - timestamp) / 1000 / 60) + const days = Math.floor(differenceInMinutes / 60 / 24) + const hours = Math.floor((differenceInMinutes - days * 60 * 24) / 60) + const minutes = differenceInMinutes - days * 60 * 24 - hours * 60 + return `${days > 0 ? `${days}D ` : ''}${hours > 0 ? `${hours}H ` : ''}${minutes}m` +} \ No newline at end of file diff --git a/src/components/menu/TopMenu.tsx b/src/components/menu/TopMenu.tsx index ddcb371..b32553d 100644 --- a/src/components/menu/TopMenu.tsx +++ b/src/components/menu/TopMenu.tsx @@ -7,6 +7,8 @@ import React from "react"; import { CharacterButton } from "./buttons/CharacterButton"; import { ItemButton } from "./buttons/ItemButton"; import {PlayerButton} from "./buttons/PlayerButton"; +import {hasRole} from "../../utils/role-utils"; +import {FoundryButton} from "./buttons/FoundryButton"; export const TopMenu = ({ roles }: { roles: Role[] }) => { const { colorMode } = useColorMode() @@ -28,6 +30,7 @@ export const TopMenu = ({ roles }: { roles: Role[] }) => { + {hasRole(roles, Role.MANAGE_SESSIONS) && } diff --git a/src/components/menu/buttons/FoundryButton.tsx b/src/components/menu/buttons/FoundryButton.tsx new file mode 100644 index 0000000..bad5f5c --- /dev/null +++ b/src/components/menu/buttons/FoundryButton.tsx @@ -0,0 +1,22 @@ +import { ChevronDownIcon } from "@chakra-ui/icons"; +import { Button, Menu, MenuButton, MenuItem, MenuList } from "@chakra-ui/react"; +import { Link } from "react-router-dom"; + +export const FoundryButton = ({ backgroundColor }: { backgroundColor: string }) => { + return ( + + } + background={backgroundColor} + backdropFilter="saturate(180%) blur(5px)" + borderRadius='0' + > + Foundry + + + Manage Instances + + + ); +}; diff --git a/src/models/foundry/InstanceInfo.ts b/src/models/foundry/InstanceInfo.ts new file mode 100644 index 0000000..eee2849 --- /dev/null +++ b/src/models/foundry/InstanceInfo.ts @@ -0,0 +1,10 @@ +export interface InstanceInfo { + id: string, + url: string, + status: string, + masterName: string, + cpu: number, + memory: number, + uptime: number, + diskSize: number, +} \ No newline at end of file diff --git a/src/pages/CharactersPage.tsx b/src/pages/CharactersPage.tsx index 60963a2..f1f06ae 100644 --- a/src/pages/CharactersPage.tsx +++ b/src/pages/CharactersPage.tsx @@ -1,4 +1,4 @@ -import {Alert, AlertIcon, Center, AlertTitle, AlertDescription, useBreakpointValue} from "@chakra-ui/react"; +import {Alert, AlertIcon, Center, AlertTitle, AlertDescription} from "@chakra-ui/react"; import {useGetAllCharactersForSelfQuery} from "../services/character"; import { CharacterList } from "../components/character/CharacterList"; diff --git a/src/pages/RedirectPage.tsx b/src/pages/RedirectPage.tsx new file mode 100644 index 0000000..a5caf25 --- /dev/null +++ b/src/pages/RedirectPage.tsx @@ -0,0 +1,52 @@ +import {useNavigate, useParams} from "react-router-dom"; +import {useStartInstanceQuery} from "../services/foundry"; +import {Alert, AlertIcon, AlertTitle, Center, Flex, Heading, Progress} from "@chakra-ui/react"; +import React, {useEffect, useState} from "react"; + +export const RedirectPage = () => { + const { instanceUrl } = useParams() + const [progressValue, setProgressValue] = useState(0); + const navigate = useNavigate(); + const { data, isSuccess, isError } = useStartInstanceQuery(instanceUrl!!, { skip: !instanceUrl}) + + useEffect(() => { + if(!instanceUrl) { + setTimeout(() => { + navigate("/") + }, 3000) + } + }, [instanceUrl, navigate]) + + useEffect(() => { + if(isSuccess) { + setInterval(() => { + setProgressValue(current => current + 1) + }, 100) + } + }, [isSuccess]) + + useEffect(() => { + if (!!data && progressValue === 100) { + window.location.replace(data) + } + }, [data, navigate, progressValue]); + + return
+ {!instanceUrl && ( + + + Invalid redirect, you will return to the home page shortly + + )} + {isError && ( + + + Invalid foundry instance: {instanceUrl} + + )} + {isSuccess && + Instance successfully activated, you will be redirected shortly + + } +
+}; diff --git a/src/pages/foundry/ManageInstancePage.tsx b/src/pages/foundry/ManageInstancePage.tsx new file mode 100644 index 0000000..89e73f6 --- /dev/null +++ b/src/pages/foundry/ManageInstancePage.tsx @@ -0,0 +1,104 @@ +import {useStreamInstancesInfoQuery} from "../../services/foundry"; +import {Alert, AlertIcon, AlertTitle, Center, Flex, Grid, GridItem, Heading, Text} from "@chakra-ui/react"; +import {StackedSkeleton} from "../../components/ui/StackedSkeleton"; +import React from "react"; +import {FoundryRow} from "../../components/foundry/FoundryRow"; + +export const ManageInstancePage = () => { + + const { data, isLoading, isError, isSuccess } = useStreamInstancesInfoQuery(); + + return ( +
+ Manage Foundry Instances +
+ {isLoading && } + {isError && ( + + + There was an error while loading the instances + + )} + {isSuccess && ( + + + + Instance Name + + + + + Master + + + + + Uptime + + + + + Status + + + + + CPU + + + + + RAM + + + + + Disk Occupation + + + + + Terminate + + + {data != null && data["deposito-pg"] != null && } + {data != null && Object.values(data) + .filter(it => it.id !== "deposito-pg") + .map(it => ) + } + + )} +
) +} diff --git a/src/services/foundry.ts b/src/services/foundry.ts new file mode 100644 index 0000000..f2e56ce --- /dev/null +++ b/src/services/foundry.ts @@ -0,0 +1,71 @@ +import {createApi, fetchBaseQuery} from "@reduxjs/toolkit/query/react"; +import {AuthState} from "../store/auth/auth-slice"; +import {InstanceInfo} from "../models/foundry/InstanceInfo"; +import {QueryReturnValue} from "@reduxjs/toolkit/dist/query/baseQueryTypes"; +import {FetchBaseQueryError} from "@reduxjs/toolkit/dist/query/react"; + +export const foundryApi = createApi({ + reducerPath: "foundryApi", + baseQuery: fetchBaseQuery({ + baseUrl: `${process.env.REACT_APP_KAIRON_API_URL}/foundry`, + }), + endpoints: (builder) => ({ + startInstance: builder.query({ + query: instanceUrl => ({ + url: `/inactive/${instanceUrl}`, + responseHandler: 'text' + }), + }), + stopInstance: builder.mutation({ + queryFn: async (instanceId, api, _, baseMutation) => { + const { + auth: { jwt }, + } = api.getState() as { auth: AuthState } + + return baseMutation({ + url: `/${instanceId}`, + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Access-Control-Allow-Origin': '*', + }, + responseHandler: 'text', + }) as QueryReturnValue + } + }), + streamInstancesInfo: builder.query<{[key: string]: InstanceInfo}, void>({ + queryFn: () => ({ data: {} }), + async onCacheEntryAdded( + _, + { updateCachedData, cacheDataLoaded, cacheEntryRemoved, getState } + ) { + await cacheDataLoaded + const { + auth: { jwt }, + } = getState() as unknown as { auth: AuthState } + const eventSource = new EventSource(`${process.env.REACT_APP_KAIRON_API_URL}/foundry/info?jwt=${jwt}`) + + eventSource.addEventListener("foundry-info", (event: MessageEvent) => { + const newData: {[key: string]: InstanceInfo} = JSON.parse(event.data); + updateCachedData((_) => { + return newData + }) + }) + + eventSource.onerror = (error) => { + console.error('SSE error:', error); + eventSource.close() + }; + + await cacheEntryRemoved; + eventSource.close() + }, + }), + }), +}); + +export const { + useStartInstanceQuery, + useStopInstanceMutation, + useStreamInstancesInfoQuery +} = foundryApi; diff --git a/src/store/index.ts b/src/store/index.ts index 3fd0d2f..08089a4 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -10,6 +10,7 @@ import { itemApi } from "../services/item"; import {recipesReducer} from "./recipes/recipes-slice"; import {playerApi} from "../services/player"; import {utilitiesApi} from "../services/utilities"; +import {foundryApi} from "../services/foundry"; export const store = configureStore({ @@ -24,6 +25,7 @@ export const store = configureStore({ [playerApi.reducerPath]: playerApi.reducer, [sessionApi.reducerPath]: sessionApi.reducer, [utilitiesApi.reducerPath]: utilitiesApi.reducer, + [foundryApi.reducerPath]: foundryApi.reducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware() @@ -36,6 +38,7 @@ export const store = configureStore({ .concat(itemApi.middleware) .concat(playerApi.middleware) .concat(utilitiesApi.middleware) + .concat(foundryApi.middleware) }) // Infer the `RootState` and `AppDispatch` types from the store itself diff --git a/src/utils/jwt-utils.ts b/src/utils/jwt-utils.ts index 6373272..f0873f2 100644 --- a/src/utils/jwt-utils.ts +++ b/src/utils/jwt-utils.ts @@ -20,7 +20,8 @@ export enum Role { PLAYER, MANAGE_CHARACTERS, MANAGE_ITEMS, - DELETE_ITEMS + DELETE_ITEMS, + MANAGE_FOUNDRY } const reverseEnum: { [key: string]: Role} = { @@ -29,7 +30,8 @@ const reverseEnum: { [key: string]: Role} = { "p": Role.PLAYER, "mC": Role.MANAGE_CHARACTERS, "mI": Role.MANAGE_ITEMS, - "dI": Role.DELETE_ITEMS + "dI": Role.DELETE_ITEMS, + "mF": Role.MANAGE_FOUNDRY } export function getRolesFromJwt(jwt: string | null): Role[] { @@ -40,9 +42,6 @@ export function getRolesFromJwt(jwt: string | null): Role[] { } try { const rawRoles = JSON.parse(JSON.parse(a2b(parts[1]))["r"]) as string[]; - if(!rawRoles) { - throw Error("Invalid JWT format"); - } return rawRoles.map( it => { const role = reverseEnum[it] if(!!role) {