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 (
+
+ );
+};
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) {