added frontend docker
This commit is contained in:
parent
1bead994bd
commit
da01815f27
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@ -0,0 +1,17 @@
|
||||
FROM node:alpine
|
||||
EXPOSE 3000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json .
|
||||
COPY package-lock.json .
|
||||
COPY tsconfig.json .
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY startup.sh .
|
||||
|
||||
COPY public public
|
||||
COPY src src
|
||||
|
||||
ENTRYPOINT [ "/usr/bin/env", "./startup.sh" ]
|
19475
package-lock.json
generated
Normal file
19475
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
package.json
Normal file
57
package.json
Normal file
@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "a-cool-games-manager",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@rjsf/core": "^5.14.2",
|
||||
"@rjsf/mui": "^5.14.2",
|
||||
"@rjsf/utils": "^5.14.2",
|
||||
"@rjsf/validator-ajv8": "^5.14.2",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^16.18.63",
|
||||
"@types/react": "^18.2.38",
|
||||
"@types/react-dom": "^18.2.16",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"axios": "^1.6.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsonpath": "^1.1.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-jsonschema-form": "^1.8.1",
|
||||
"react-router-dom": "^6.19.0",
|
||||
"react-routes": "^0.2.6",
|
||||
"react-scripts": "5.0.1",
|
||||
"serve": "^14.2.1",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "/usr/bin/env react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.8 KiB |
43
public/index.html
Normal file
43
public/index.html
Normal file
@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
BIN
public/logo192.png
Normal file
BIN
public/logo192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
25
public/manifest.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
38
src/App.css
Normal file
38
src/App.css
Normal file
@ -0,0 +1,38 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
7
src/App.test.tsx
Normal file
7
src/App.test.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
});
|
144
src/App.tsx
Normal file
144
src/App.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import { Box, Paper, ThemeProvider, List, ListItem, ListItemButton, ListItemText, SwipeableDrawer, ListItemIcon, IconButton, AppBar, Toolbar, PaletteMode, createTheme, useMediaQuery, useTheme } from "@mui/material";
|
||||
import React, { Dispatch, ReactNode, useState } from "react";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { ApiWrapper, getDesignTokens, GlobalOpenApi, GlobalUserInfo } from "./common";
|
||||
import { LoginPage } from "./login";
|
||||
import ServersBoard from "./servers";
|
||||
import MenuIcon from '@mui/icons-material/Menu';
|
||||
import StorageIcon from '@mui/icons-material/Storage';
|
||||
import WebIcon from '@mui/icons-material/Web';
|
||||
import Person3Icon from '@mui/icons-material/Person3';
|
||||
import { UsersPage } from "./users";
|
||||
import { BrowsersPage } from "./browsers";
|
||||
import { SignupPage } from "./signup";
|
||||
import Brightness4Icon from '@mui/icons-material/Brightness4';
|
||||
import Brightness7Icon from '@mui/icons-material/Brightness7';
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
const ColorModeContext = React.createContext({ toggleColorMode: () => { } });
|
||||
|
||||
function Menu(props: { children: ReactNode, setMode: Dispatch<PaletteMode> }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const theme = useTheme();
|
||||
const toggleDrawer = (newOpen: boolean) => () => {
|
||||
setOpen(newOpen);
|
||||
};
|
||||
const colorMode = React.useContext(ColorModeContext);
|
||||
|
||||
return (
|
||||
|
||||
<Box component={Paper} width='100vw' height='100vh' overflow={'clip'} maxHeight='-webkit-fill-available'>
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
size="large"
|
||||
edge="start"
|
||||
color="inherit"
|
||||
aria-label="menu"
|
||||
sx={{ mr: 2 }}
|
||||
onClick={() => { setOpen(true) }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Box component="div" sx={{ flexGrow: 1 }} />
|
||||
<IconButton size='large' edge='end' color='inherit' aria-label="menu" sx={{ mr: 2 }} onClick={colorMode.toggleColorMode}>
|
||||
{theme.palette.mode === 'dark' ? <Brightness7Icon /> : <Brightness4Icon />}
|
||||
</IconButton>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<SwipeableDrawer
|
||||
onClose={toggleDrawer(false)}
|
||||
onOpen={toggleDrawer(true)}
|
||||
disableSwipeToOpen={false}
|
||||
ModalProps={
|
||||
{
|
||||
keepMounted: true,
|
||||
}
|
||||
}
|
||||
open={open}
|
||||
sx={{ minWidth: 'max-content' }}
|
||||
>
|
||||
<List>
|
||||
<ListItem key="Servers">
|
||||
<ListItemButton href='/servers'>
|
||||
<ListItemIcon>
|
||||
<StorageIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={'Servers'} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<ListItem key="Users" >
|
||||
<ListItemButton href='/users'>
|
||||
<ListItemIcon>
|
||||
<Person3Icon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={'Users'} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<ListItem key="Browsers">
|
||||
<ListItemButton href='/browsers'>
|
||||
<ListItemIcon >
|
||||
<WebIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={'Browsers'} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
</SwipeableDrawer>
|
||||
{props.children}
|
||||
</Box>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
let themeName = Cookies.get('theme')
|
||||
const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
|
||||
if (!themeName) {
|
||||
themeName = prefersDarkMode ? 'dark' : 'light'
|
||||
}
|
||||
const [mode, setMode] = React.useState<PaletteMode>(themeName as PaletteMode);
|
||||
const colorMode = React.useMemo(
|
||||
() => ({
|
||||
toggleColorMode: () => {
|
||||
setMode((prevMode) => {
|
||||
const mode = prevMode === 'light' ? 'dark' : 'light'
|
||||
Cookies.set('theme', mode)
|
||||
return mode
|
||||
});
|
||||
|
||||
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const theme = React.useMemo(() => createTheme(getDesignTokens(mode)), [mode]);
|
||||
|
||||
return (
|
||||
<ColorModeContext.Provider value={colorMode}>
|
||||
<Box height={'100vh'} width={'100vw'} overflow='clip' maxHeight='-webkit-fill-available'>
|
||||
<ThemeProvider theme={theme}>
|
||||
<BrowserRouter>
|
||||
<ApiWrapper>
|
||||
<GlobalOpenApi>
|
||||
<GlobalUserInfo>
|
||||
<Menu setMode={setMode}>
|
||||
<Routes>
|
||||
<Route path='login' element={<LoginPage />} />
|
||||
<Route path='signup' element={<SignupPage />} />
|
||||
<Route path='servers' element={<ServersBoard />} />
|
||||
<Route path='users' element={<UsersPage />} />
|
||||
<Route path='browsers' element={<BrowsersPage />} />
|
||||
<Route index element={<Navigate to='/servers' />} />
|
||||
</Routes>
|
||||
</Menu>
|
||||
</GlobalUserInfo>
|
||||
</GlobalOpenApi>
|
||||
</ApiWrapper>
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</Box>
|
||||
</ColorModeContext.Provider>
|
||||
)
|
||||
}
|
92
src/browsers.tsx
Normal file
92
src/browsers.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { TableRow, TableCell, TableContainer, TableHead, Table, Button, Popover, Paper, TableBody, Chip, Link, ButtonGroup, Modal, Box } from "@mui/material"
|
||||
import React, { useContext, Dispatch, useState, useEffect, createContext, Context } from "react"
|
||||
import Form from "@rjsf/mui"
|
||||
import { apiAuthenticatedContext, useApiDoc, api, ActionItem, formModalStyle, DataTable, useActions, ActionInfo, ActionGroup, actionIdentifierContext } from "./common"
|
||||
import validator from '@rjsf/validator-ajv8';
|
||||
import { Browser, ServerInfo } from "./interfaces";
|
||||
import { loadServers } from "./servers";
|
||||
import { JSONSchema7TypeName } from "json-schema";
|
||||
|
||||
const browserContext: Context<Browser> = createContext({} as Browser)
|
||||
const browserActionContext: Context<ActionInfo[]|null> = createContext(null as ActionInfo[]|null)
|
||||
|
||||
function FakeAction(props: {action: ActionInfo, browser: Browser}){
|
||||
return <Button rel="noopener noreferrer" target="_blank" href={`https://${props.browser.url}`}>{props.action.name}</Button>
|
||||
}
|
||||
|
||||
function BrowserActions() {
|
||||
const actions = useContext(browserActionContext)
|
||||
const browser = useContext(browserContext)
|
||||
return <actionIdentifierContext.Provider value={browser.id_}>
|
||||
<ActionGroup actions={actions? actions:[]} identifierSubstring="browser_id">
|
||||
<FakeAction action={{name: 'Browse', requestType: 'post', endpoint: '', args:{}}} browser={browser}/>
|
||||
</ActionGroup>
|
||||
</actionIdentifierContext.Provider>
|
||||
}
|
||||
|
||||
export function BrowsersPage({ }) {
|
||||
const [apiAuthenticated, setApiAuthenticated] = useContext(apiAuthenticatedContext)
|
||||
const [browsers, setBrowsers]: [Browser[], Dispatch<Browser[]>] = useState([]as Browser[])
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiAuthenticated) {
|
||||
return
|
||||
}
|
||||
api.get('/browsers').then((response) => { setBrowsers(response.data) }).catch(
|
||||
(error) => {
|
||||
console.log('Failed to get Browsers: ' + error);
|
||||
if (error.response) {
|
||||
if (error.response.status === 401) {
|
||||
setApiAuthenticated(false)
|
||||
}
|
||||
else if (error.response.status === 403) {
|
||||
setApiAuthenticated(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const interval = setInterval(() => {
|
||||
api.get('/browsers').then((response) => { setBrowsers(response.data) }).catch(
|
||||
(error) => {
|
||||
console.log('Failed to get Browsers: ' + error);
|
||||
if (error.response) {
|
||||
if (error.response.status === 401) {
|
||||
setApiAuthenticated(false)
|
||||
}
|
||||
else if (error.response.status === 403) {
|
||||
setApiAuthenticated(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
}, 5000
|
||||
);
|
||||
return () => { clearInterval(interval) }
|
||||
}, [apiAuthenticated])
|
||||
|
||||
let browserComponents = []
|
||||
|
||||
for (let browser of browsers) {
|
||||
browserComponents.push(
|
||||
<browserContext.Provider value={browser}>
|
||||
<TableRow>
|
||||
<TableCell>{browser.owner_id}</TableCell>
|
||||
<TableCell>{browser.connected_to.user_id}</TableCell>
|
||||
<TableCell>{browser.connected_to.image.name}</TableCell>
|
||||
<TableCell>{browser.connected_to.image.version}</TableCell>
|
||||
<TableCell>
|
||||
<BrowserActions/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</browserContext.Provider>
|
||||
)
|
||||
}
|
||||
return <DataTable headers={['Browser Owner', 'Server Owner', 'Server Game', 'Game Version', 'Actions']}>
|
||||
<browserActionContext.Provider value={useActions(api, '/browsers/{browser_id}')}>
|
||||
{browserComponents}
|
||||
</browserActionContext.Provider>
|
||||
</DataTable>
|
||||
}
|
485
src/common.tsx
Normal file
485
src/common.tsx
Normal file
@ -0,0 +1,485 @@
|
||||
import axios, { AxiosInstance, AxiosResponse } from "axios";
|
||||
import React, { Context, Dispatch, ReactNode, createContext, useContext, useState } from "react";
|
||||
import { useLocation, Navigate } from "react-router-dom";
|
||||
import Cookies from 'js-cookie'
|
||||
import { Box, Button, ButtonGroup, ButtonOwnProps, Modal, PaletteMode, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TextField } from "@mui/material";
|
||||
import { Form } from "@rjsf/mui";
|
||||
import validator from '@rjsf/validator-ajv8';
|
||||
import { blue, grey } from "@mui/material/colors";
|
||||
import { OpenAPISchema, User } from "./interfaces";
|
||||
import { JSONSchema7 } from "json-schema";
|
||||
import { IChangeEvent } from "@rjsf/core";
|
||||
import { RJSFSchema, WidgetProps } from "@rjsf/utils";
|
||||
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import Grow from '@mui/material/Grow';
|
||||
import Popper from '@mui/material/Popper';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
|
||||
export const apiAuthenticatedContext: Context<[boolean, Dispatch<boolean>]> = createContext([false, (value: boolean) => {}] as [boolean, Dispatch<boolean>])
|
||||
|
||||
|
||||
export const formModalStyle = {
|
||||
position: 'absolute' as 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '70%',
|
||||
bgcolor: 'background.paper',
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
}
|
||||
|
||||
export const getDesignTokens = (mode: PaletteMode) => ({
|
||||
palette: {
|
||||
mode,
|
||||
...(mode === 'light'
|
||||
? {
|
||||
// palette values for light mode
|
||||
primary: blue,
|
||||
divider: blue[200],
|
||||
background: {
|
||||
default: grey[200],
|
||||
light: grey[100],
|
||||
},
|
||||
text: {
|
||||
primary: grey[900],
|
||||
secondary: grey[800],
|
||||
},
|
||||
}
|
||||
: {
|
||||
// palette values for dark mode
|
||||
primary: grey,
|
||||
divider: grey[700],
|
||||
background: {
|
||||
default: grey[900],
|
||||
paper: grey[900],
|
||||
light: grey[700],
|
||||
},
|
||||
text: {
|
||||
primary: '#fff',
|
||||
secondary: grey[500],
|
||||
},
|
||||
}),
|
||||
},
|
||||
components: {
|
||||
MuiTypography: {
|
||||
defaultProps: {
|
||||
color: 'text.primary'
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
const API_URL = `${process.env.REACT_APP_API_SCHEME}://${process.env.REACT_APP_API_URL}`
|
||||
|
||||
export const api: AxiosInstance = axios.create({
|
||||
baseURL: API_URL,
|
||||
});
|
||||
|
||||
console.log(process.env)
|
||||
|
||||
export function ApiWrapper(p: { children: ReactNode}) {
|
||||
const {children} = p
|
||||
const token = Cookies.get('token')
|
||||
if (token) {
|
||||
api.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||
|
||||
}
|
||||
const [apiAuthenticated, setApiAuthenticated] = useState(Boolean(token))
|
||||
if (!apiAuthenticated) {
|
||||
Cookies.remove('token', { path: '/', domain: 'games.acooldomain.co' })
|
||||
}
|
||||
const path = useLocation()
|
||||
return (<apiAuthenticatedContext.Provider value={[apiAuthenticated, setApiAuthenticated]}>
|
||||
{children}
|
||||
{!apiAuthenticated && (path.pathname !== '/login' && path.pathname !== '/signup') && <Navigate to='/login' />}
|
||||
</apiAuthenticatedContext.Provider>)
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function useApiDoc(api: AxiosInstance, path: string, method: 'get' | 'post' | 'delete') {
|
||||
const [apiRecord, setApiRecord] = useState(null as OpenAPISchema|null)
|
||||
api.get('/openapi.json').then(
|
||||
(value) => {
|
||||
setApiRecord(value.data)
|
||||
}
|
||||
)
|
||||
|
||||
if (apiRecord) {
|
||||
const schema = apiRecord.paths[path][method]
|
||||
if (!schema){
|
||||
return
|
||||
}
|
||||
|
||||
let formSchema: JSONSchema7 = { title: schema.summary, type: 'object', required: [], properties: {}, definitions: apiRecord.components, default: {} }
|
||||
if (schema.requestBody) {
|
||||
formSchema = schema.requestBody.content['application/json'].schema
|
||||
formSchema.definitions = apiRecord.components
|
||||
}
|
||||
|
||||
return JSON.parse(JSON.stringify(formSchema).replaceAll('#/components', '#/definitions'))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
export interface ActionInfo {
|
||||
name: string
|
||||
requestType: 'post' | 'get' | 'delete'
|
||||
endpoint: string
|
||||
args: {}
|
||||
permissions?: string[]
|
||||
response_action?: 'Ignore' | 'Browse'
|
||||
}
|
||||
|
||||
|
||||
export function useActions(api: AxiosInstance, path_prefix: string): ActionInfo[] {
|
||||
const openapi: OpenAPISchema|null = useContext(OpenApiContext)
|
||||
let [actions, setActions]: [Record<string, ActionInfo[]>, Dispatch<Record<string, ActionInfo[]>>] = useState({})
|
||||
|
||||
if (actions[path_prefix] && actions[path_prefix].length > 0) {
|
||||
return actions[path_prefix]
|
||||
}
|
||||
if (openapi === null) {
|
||||
return []
|
||||
}
|
||||
|
||||
let responseActions: ActionInfo[] = []
|
||||
let paths = openapi.paths
|
||||
for (let [path, request] of Object.entries(paths)) {
|
||||
if (path.startsWith(path_prefix)) {
|
||||
for (let [method, schema] of Object.entries(request)) {
|
||||
let formSchema: JSONSchema7 = { title: schema.summary, type: 'object', required: [], properties: {}, definitions: openapi.components, default: {} }
|
||||
if (schema.requestBody) {
|
||||
formSchema = schema.requestBody.content['application/json'].schema
|
||||
formSchema.definitions = openapi.components
|
||||
}
|
||||
responseActions.push(
|
||||
{
|
||||
name: schema.summary,
|
||||
args: JSON.parse(JSON.stringify(formSchema).replaceAll('#/components', '#/definitions')),
|
||||
requestType: (method as 'get' | 'post' | 'delete'),
|
||||
endpoint: path,
|
||||
permissions: schema.permissions,
|
||||
response_action: schema.api_response,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actions[path_prefix] = responseActions
|
||||
setActions(actions)
|
||||
|
||||
if (actions[path_prefix]) {
|
||||
return actions[path_prefix]
|
||||
}
|
||||
else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function useAction(p: {path: string, method: 'post' | 'get' | 'delete'}): ActionInfo|undefined{
|
||||
const apiDoc: OpenAPISchema|null = useContext(OpenApiContext)
|
||||
const [action, setAction]: [ActionInfo|null, Dispatch<ActionInfo|null>] = useState(null as ActionInfo|null)
|
||||
if (action != null){
|
||||
return action
|
||||
}
|
||||
|
||||
if (!apiDoc){
|
||||
return
|
||||
}
|
||||
const schema = apiDoc.paths[p.path][p.method]
|
||||
if (!schema){
|
||||
return
|
||||
}
|
||||
|
||||
let formSchema: JSONSchema7 = { title: schema.summary, type: 'object', required: [], properties: {}, definitions: apiDoc.components, default: {} }
|
||||
if (schema.requestBody) {
|
||||
formSchema = schema.requestBody.content['application/json'].schema
|
||||
formSchema.definitions = apiDoc.components
|
||||
}
|
||||
const calculatedAction = {
|
||||
name: schema.summary,
|
||||
args: JSON.parse(JSON.stringify(formSchema).replaceAll('#/components', '#/definitions')),
|
||||
requestType: p.method,
|
||||
endpoint: p.path,
|
||||
permissions: schema.permissions,
|
||||
response_action: schema.api_response,
|
||||
}
|
||||
setAction(calculatedAction)
|
||||
|
||||
return calculatedAction
|
||||
|
||||
}
|
||||
|
||||
|
||||
interface Options{
|
||||
label: string
|
||||
const: string
|
||||
}
|
||||
|
||||
export const actionIdentifierContext: Context<string> = createContext('')
|
||||
|
||||
function FetcherField(props: WidgetProps){
|
||||
const jp = require('jsonpath')
|
||||
const [options2, setOptions]: [Options[]|null, Dispatch<Options[]>] = useState(null as Options[]|null)
|
||||
const {schema, registry, options, ...newProps} = props
|
||||
const {SelectWidget} = registry.widgets
|
||||
|
||||
if (!schema.fetch_url){
|
||||
return <TextField onChange={(event)=>(props.onChange(event.target.value))} value={props.value} label={props.label}/>
|
||||
}
|
||||
|
||||
if (options2 === null){
|
||||
api.get(schema.fetch_url as string).then((event)=>{
|
||||
let newOptions: Options[] = []
|
||||
for (let response of event.data){
|
||||
newOptions.push({
|
||||
const: jp.query(response, `$.${schema.fetch_key_path}`).join(' '),
|
||||
label: jp.query(response, `$.${schema.fetch_display_path}`).join(' '),
|
||||
})
|
||||
}
|
||||
|
||||
setOptions(newOptions)
|
||||
})
|
||||
}
|
||||
return <SelectWidget {...newProps} schema={{oneOf: options2?options2:[]}} registry={registry} options={{enumOptions: (options2?options2:[]).map((value: Options)=>({label: value.label, value: value.const}))}}/>
|
||||
}
|
||||
|
||||
|
||||
function isUserAllowed(user: User|null, action: ActionInfo): boolean{
|
||||
if (user === null){
|
||||
return false
|
||||
}
|
||||
|
||||
const isAdmin = user.permissions.includes('admin')
|
||||
if (isAdmin){
|
||||
return true
|
||||
}
|
||||
|
||||
if (!action.permissions){
|
||||
return true
|
||||
}
|
||||
|
||||
if (action.permissions.every((v)=>(user.permissions.includes(v)))){
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
export function ActionItem(p: { action: ActionInfo, identifierSubstring?: string, sx?: ButtonOwnProps, variant?: any, onClick?: Function }) {
|
||||
const actionIdentifier: string = useContext(actionIdentifierContext)
|
||||
const identifierSubstring = (typeof p.identifierSubstring !== 'undefined') ? p.identifierSubstring : ''
|
||||
const user = useContext(UserInfoContext)
|
||||
|
||||
const [form, setForm] = useState(false);
|
||||
const [formData, setFormData]: [RJSFSchema, Dispatch<RJSFSchema>] = useState({})
|
||||
|
||||
|
||||
function handleSubmit() {
|
||||
let url = p.action.endpoint.replaceAll(`{${identifierSubstring}}`, actionIdentifier)
|
||||
let promise: Promise<AxiosResponse<any, any>>|null = null
|
||||
switch (p.action.requestType) {
|
||||
case 'post': {
|
||||
promise = api.post(url, formData)
|
||||
break
|
||||
}
|
||||
case 'get': {
|
||||
if (formData){
|
||||
console.warn('get can get no arguments, dropping')
|
||||
}
|
||||
promise = api.get(url)
|
||||
break
|
||||
}
|
||||
case 'delete': {
|
||||
if (formData){
|
||||
console.warn('delete can get no arguments, dropping')
|
||||
}
|
||||
promise = api.delete(url)
|
||||
break
|
||||
}
|
||||
}
|
||||
switch (p.action.response_action){
|
||||
case 'Browse':{
|
||||
if (promise !== null){
|
||||
promise.then((event)=>{window.open(`https://${event.data}`)})
|
||||
}
|
||||
}
|
||||
}
|
||||
setForm(false)
|
||||
setFormData({})
|
||||
}
|
||||
|
||||
function onFormChange(args: IChangeEvent<any, RJSFSchema, any>) {
|
||||
setFormData(args.formData)
|
||||
}
|
||||
|
||||
return (<>
|
||||
<Button variant={p.variant} disabled={!isUserAllowed(user, p.action)} onClick={() => { if (p.onClick) { p.onClick() } setForm(true) }} sx={p.sx}>{p.action.name}</Button >
|
||||
<Modal
|
||||
onClose={() => { setForm(false); setFormData({}); }}
|
||||
open={form}
|
||||
>
|
||||
<Box sx={formModalStyle}>
|
||||
<Form validator={validator} widgets={{TextWidget: FetcherField}} schema={p.action.args} onChange={onFormChange} formData={formData} onSubmit={handleSubmit} />
|
||||
</Box>
|
||||
</Modal>
|
||||
</>)
|
||||
}
|
||||
|
||||
export function ActionGroup(p: { actions: ActionInfo[], identifierSubstring?: string, children?: ReactNode}) {
|
||||
const actionItems: any[] = p.actions.map((action, index, array) => (<ActionItem action={action} identifierSubstring={p.identifierSubstring} /> ))
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const anchorRef = React.useRef<HTMLDivElement>(null);
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
||||
const user = useContext(UserInfoContext)
|
||||
|
||||
for (let child of React.Children.toArray(p.children)){
|
||||
actionItems.push(child)
|
||||
}
|
||||
|
||||
const handleMenuItemClick = (
|
||||
event: React.MouseEvent<HTMLLIElement, MouseEvent>,
|
||||
index: number,
|
||||
) => {
|
||||
setSelectedIndex(index);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
setOpen((prevOpen) => !prevOpen);
|
||||
};
|
||||
|
||||
const handleClose = (event: Event) => {
|
||||
if (
|
||||
anchorRef.current &&
|
||||
anchorRef.current.contains(event.target as HTMLElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ButtonGroup variant="outlined" ref={anchorRef} aria-label="split button">
|
||||
{actionItems[selectedIndex]}
|
||||
<Button
|
||||
size="small"
|
||||
aria-controls={open ? 'split-button-menu' : undefined}
|
||||
aria-expanded={open ? 'true' : undefined}
|
||||
aria-label="select merge strategy"
|
||||
aria-haspopup="menu"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<ArrowDropDownIcon />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<Popper
|
||||
sx={{
|
||||
zIndex: 1,
|
||||
}}
|
||||
open={open}
|
||||
anchorEl={anchorRef.current}
|
||||
role={undefined}
|
||||
transition
|
||||
disablePortal
|
||||
>
|
||||
{({ TransitionProps, placement }) => (
|
||||
<Grow
|
||||
{...TransitionProps}
|
||||
style={{
|
||||
transformOrigin:
|
||||
placement === 'bottom' ? 'center top' : 'center bottom',
|
||||
}}
|
||||
>
|
||||
<Paper>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<MenuList id="split-button-menu" autoFocusItem>
|
||||
{actionItems.map((option, index) => {
|
||||
if (!option.props){
|
||||
console.log(actionItems)
|
||||
}
|
||||
return <MenuItem
|
||||
key={option.props.action.name}
|
||||
selected={index === selectedIndex}
|
||||
onClick={(event) => handleMenuItemClick(event, index)}
|
||||
disabled={!isUserAllowed(user, option.props.action)}
|
||||
>
|
||||
{option.props.action.name}
|
||||
</MenuItem>
|
||||
}
|
||||
)
|
||||
}
|
||||
</MenuList>
|
||||
</ClickAwayListener>
|
||||
</Paper>
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function DataTable(props: { headers: string[], children: ReactNode, actionInfo?: ActionInfo, actionHook?: Function }) {
|
||||
const { children, headers, actionInfo, actionHook } = props
|
||||
return <Box padding={4} overflow='clip'>
|
||||
<TableContainer component={Paper} sx={{maxHeight: '80svh'}}>
|
||||
<Table stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow sx={{ backgroundColor: 'background.light', fontWeight: 'bold' }}>
|
||||
{headers.map((value, index, array) => (<TableCell sx={{ backgroundColor: 'background.light', fontWeight: 'bold' }}>{value}</TableCell>))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{children}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
{(actionInfo && <Box marginTop={2} overflow='clip'>
|
||||
<ActionItem variant="contained" action={actionInfo} onClick={actionHook} />
|
||||
</Box>)}
|
||||
</Box>
|
||||
}
|
||||
|
||||
|
||||
export const UserInfoContext: Context<User|null> = createContext(null as User|null)
|
||||
|
||||
|
||||
export function GlobalUserInfo(props: {children: any}){
|
||||
const [user, setUser]: [User|null, Dispatch<User|null>] = useState(null as User|null)
|
||||
const [apiAuthenticated, _] = useContext(apiAuthenticatedContext)
|
||||
|
||||
|
||||
|
||||
if (user === null && apiAuthenticated){
|
||||
api.get('/users/@me').then((event)=>{setUser(event.data)}).catch(()=>{setUser(null)})
|
||||
}
|
||||
|
||||
return <UserInfoContext.Provider value={user}>
|
||||
{props.children}
|
||||
</UserInfoContext.Provider>
|
||||
}
|
||||
|
||||
|
||||
export const OpenApiContext: Context<OpenAPISchema|null> = createContext(null as OpenAPISchema|null)
|
||||
export function GlobalOpenApi(props: {children: any}){
|
||||
const [openApi, setOpenApi]: [OpenAPISchema|null, Dispatch<OpenAPISchema|null>] = useState(null as OpenAPISchema|null)
|
||||
if (openApi === null){
|
||||
api.get('/openapi.json').then((event)=>{
|
||||
setOpenApi(event.data)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return <OpenApiContext.Provider value={openApi}>
|
||||
{props.children}
|
||||
</OpenApiContext.Provider>
|
||||
}
|
13
src/index.css
Normal file
13
src/index.css
Normal file
@ -0,0 +1,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
19
src/index.tsx
Normal file
19
src/index.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
56
src/interfaces.tsx
Normal file
56
src/interfaces.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { JSONSchema6, JSONSchema7 } from "json-schema"
|
||||
|
||||
export interface Port {
|
||||
number: number
|
||||
protocol: 'tcp' | 'udp'
|
||||
}
|
||||
|
||||
|
||||
export interface ImageInfo {
|
||||
id_: string
|
||||
name: string
|
||||
version: string
|
||||
ports: Port[]
|
||||
}
|
||||
|
||||
|
||||
export interface ServerInfo {
|
||||
id_: string
|
||||
name: string
|
||||
on: boolean
|
||||
user_id: string
|
||||
image: ImageInfo
|
||||
ports: Port[] | null
|
||||
domain: string
|
||||
nickname?: string
|
||||
}
|
||||
|
||||
|
||||
export interface User {
|
||||
username: string
|
||||
email: string
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
|
||||
export interface Browser {
|
||||
id_: string
|
||||
domain: string
|
||||
url: string
|
||||
owner_id: string
|
||||
connected_to: ServerInfo
|
||||
}
|
||||
|
||||
|
||||
export interface OpenApiMethodSchema {
|
||||
summary: string
|
||||
requestBody: {content: Record<string, {schema: JSONSchema7}>}
|
||||
api_response: 'Ignore' | 'Browse'
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
|
||||
export interface OpenAPISchema {
|
||||
paths: Record<string, {get?: OpenApiMethodSchema, post?: OpenApiMethodSchema, delete?: OpenApiMethodSchema}>
|
||||
components: {schema: Record<string, JSONSchema7>}
|
||||
}
|
103
src/login.tsx
Normal file
103
src/login.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { CssBaseline, Box, Typography, TextField, FormControlLabel, Checkbox, Container, Button } from "@mui/material";
|
||||
import React, { FormEventHandler, useContext } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { api, apiAuthenticatedContext } from "./common";
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
export const fetchToken = async (username: string, password: string, remember: boolean) => {
|
||||
try {
|
||||
const response = await api.post('/token', {
|
||||
username: username,
|
||||
password: password,
|
||||
remember: remember,
|
||||
}, {
|
||||
});
|
||||
return response.data.access_token;
|
||||
} catch (error) {
|
||||
console.error('Error fetching token:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export function LoginPage(props: {}) {
|
||||
const [apiAuthenticated, setApiAuthenticated] = useContext(apiAuthenticatedContext)
|
||||
|
||||
if (apiAuthenticated) {
|
||||
return <Navigate to='/' />
|
||||
}
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
const usernameFormData: FormDataEntryValue | null = data.get('username');
|
||||
const passwordFormData: FormDataEntryValue | null = data.get('password');
|
||||
if (usernameFormData === null || passwordFormData === null){
|
||||
return
|
||||
}
|
||||
const username: string = usernameFormData.toString();
|
||||
const password: string = passwordFormData.toString();
|
||||
|
||||
fetchToken(username, password, Boolean(data.get('remember'))).then(
|
||||
(token) => {
|
||||
api.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||
Cookies.set('token', token)
|
||||
setApiAuthenticated(true)
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="xs">
|
||||
<CssBaseline />
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography component="h1" variant="h5">
|
||||
Sign in
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="username"
|
||||
label="User Name"
|
||||
name="username"
|
||||
autoFocus
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Checkbox value={true} name='remember' color="primary" />}
|
||||
label="Remember me"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
1
src/logo.svg
Normal file
1
src/logo.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
After Width: | Height: | Size: 2.6 KiB |
1
src/react-app-env.d.ts
vendored
Normal file
1
src/react-app-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
15
src/reportWebVitals.ts
Normal file
15
src/reportWebVitals.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
154
src/servers.tsx
Normal file
154
src/servers.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import { AxiosInstance } from "axios"
|
||||
import { ActionGroup, ActionInfo, DataTable, UserInfoContext, actionIdentifierContext, api, apiAuthenticatedContext, useAction, useActions } from "./common"
|
||||
import React, { Context, Dispatch, createContext, useContext, useEffect, useState } from "react"
|
||||
import { TableRow, TableCell, Chip } from "@mui/material"
|
||||
import { ImageInfo, ServerInfo } from "./interfaces"
|
||||
import { JSONSchema7 } from "json-schema"
|
||||
|
||||
|
||||
|
||||
export async function loadServers(api: AxiosInstance): Promise<{ status: number, data: ServerInfo[] }> {
|
||||
let response = await api.get('/servers')
|
||||
return {
|
||||
status: response.status,
|
||||
data: response.data
|
||||
}
|
||||
}
|
||||
|
||||
const serverActionsContext: Context<ActionInfo[]> = createContext([] as ActionInfo[])
|
||||
|
||||
|
||||
function ServerItem(props: { server_info: ServerInfo }) {
|
||||
const actions = useContext(serverActionsContext)
|
||||
const [serverPermissions, setServerPermissions] = useState(null as null|string[])
|
||||
const user = useContext(UserInfoContext)
|
||||
let permissions: string[] = []
|
||||
|
||||
if (props.server_info.user_id === user?.username){
|
||||
permissions.push('admin')
|
||||
}else{
|
||||
if (serverPermissions === null){
|
||||
api.get(`/servers/${props.server_info.id_}/permissions`).then((event)=>{setServerPermissions(event.data)})
|
||||
}else{
|
||||
permissions.push(...serverPermissions)
|
||||
}
|
||||
if (user){
|
||||
permissions.push(...user.permissions)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const name = `${props.server_info.user_id}'s ${props.server_info.image.name} ${props.server_info.image.version} Server`
|
||||
|
||||
if (props.server_info.ports === null) {
|
||||
props.server_info.ports = []
|
||||
}
|
||||
|
||||
return (
|
||||
<UserInfoContext.Provider value={user? {username: user.username, email: user.email, permissions: permissions}: null}>
|
||||
<actionIdentifierContext.Provider value={props.server_info.id_}>
|
||||
<TableRow>
|
||||
<TableCell>{props.server_info.nickname}</TableCell>
|
||||
<TableCell>{props.server_info.user_id}</TableCell>
|
||||
<TableCell>{props.server_info.image.name}</TableCell>
|
||||
<TableCell>{props.server_info.image.version}</TableCell>
|
||||
<TableCell>{props.server_info.domain}</TableCell>
|
||||
<TableCell>{props.server_info.ports.map((port, index, array) => { return <Chip label={`${port.number}/${port.protocol}`} /> })}</TableCell>
|
||||
<TableCell>
|
||||
<ActionGroup actions={actions} identifierSubstring="server_id" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</actionIdentifierContext.Provider>
|
||||
</UserInfoContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export default function ServersBoard() {
|
||||
const [servers, setServers]: [ServerInfo[], Dispatch<ServerInfo[]>] = useState([] as ServerInfo[]);
|
||||
const [apiAuthenticated, setApiAuthenticated] = useContext(apiAuthenticatedContext)
|
||||
const [images, setImages] = useState([] as ImageInfo[])
|
||||
|
||||
let schema: JSONSchema7 = {
|
||||
"properties": {
|
||||
"image_id": {
|
||||
"type": "string",
|
||||
"oneOf": images.map((value, index, array) => { return { "const": value.id_, "title": `${value.name} ${value.version}` } }),
|
||||
"title": "Image Id"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"image_id"
|
||||
],
|
||||
"title": "CreateServer"
|
||||
}
|
||||
|
||||
|
||||
function handleServers() {
|
||||
if (!apiAuthenticated) {
|
||||
return
|
||||
}
|
||||
|
||||
let servers_promised = loadServers(api)
|
||||
servers_promised.then((response) => {
|
||||
setServers(response.data)
|
||||
})
|
||||
.catch(
|
||||
(error) => {
|
||||
console.log('Failed to get servers: ' + error);
|
||||
if (error.response) {
|
||||
if (error.response.status === 401) {
|
||||
setApiAuthenticated(false)
|
||||
}
|
||||
else if (error.response.status === 403) {
|
||||
setApiAuthenticated(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
handleServers()
|
||||
const interval = setInterval(() => {
|
||||
handleServers()
|
||||
}, 5000
|
||||
);
|
||||
return () => { clearInterval(interval) }
|
||||
}, [apiAuthenticated])
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<DataTable headers={['Nickname', 'Owner', 'Server', 'Version', 'Domain', 'Ports', 'Actions']} actionInfo={useAction({path: '/servers', method: 'post'})} actionHook={() => {
|
||||
api.get('/images').then((value) => {
|
||||
setImages(value.data)
|
||||
}).catch(
|
||||
(error) => {
|
||||
console.log('Failed to get images: ' + error);
|
||||
if (error.response) {
|
||||
if (error.response.status === 401) {
|
||||
setApiAuthenticated(false)
|
||||
}
|
||||
else if (error.response.status === 403) {
|
||||
setApiAuthenticated(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}}>
|
||||
<serverActionsContext.Provider value={useActions(api, '/servers/{server_id}')}>
|
||||
{
|
||||
servers.sort((s1: ServerInfo, s2: ServerInfo) => { return s1.id_ < s2.id_ ? 0 : 1 }).map(
|
||||
(value: ServerInfo, index: number, array) => {
|
||||
return <ServerItem server_info={value} />
|
||||
}
|
||||
)
|
||||
}
|
||||
</serverActionsContext.Provider>
|
||||
</DataTable>
|
||||
);
|
||||
}
|
||||
|
5
src/setupTests.ts
Normal file
5
src/setupTests.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
109
src/signup.tsx
Normal file
109
src/signup.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import { CssBaseline, Box, Typography, TextField, FormControlLabel, Checkbox, Container, Button } from "@mui/material";
|
||||
import React, { FormEvent, FormEventHandler, useContext } from "react";
|
||||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
import { api, apiAuthenticatedContext } from "./common";
|
||||
import Cookies from 'js-cookie'
|
||||
import { fetchToken } from "./login";
|
||||
|
||||
const signUp = async (username: string, password: string, token: string) => {
|
||||
try {
|
||||
const response = await api.post(`/signup?token=${token}`, {
|
||||
username: username,
|
||||
password: password,
|
||||
}, {
|
||||
});
|
||||
return response.data.access_token;
|
||||
} catch (error) {
|
||||
console.error('Error fetching token:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export function SignupPage(props: {}) {
|
||||
const [apiAuthenticated, setApiAuthenticated] = useContext(apiAuthenticatedContext)
|
||||
const [searchParam, setSearchParam] = useSearchParams();
|
||||
const token = searchParam.get('token');
|
||||
if (token === null){
|
||||
return <Navigate to='/' />
|
||||
}
|
||||
|
||||
if (apiAuthenticated) {
|
||||
return <Navigate to='/' />
|
||||
}
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
const usernameFormData: FormDataEntryValue | null = data.get('username');
|
||||
const passwordFormData: FormDataEntryValue | null = data.get('password');
|
||||
if (usernameFormData === null || passwordFormData === null){
|
||||
return
|
||||
}
|
||||
const username: string = usernameFormData.toString();
|
||||
const password: string = passwordFormData.toString();
|
||||
|
||||
|
||||
signUp(username, password, token).then(
|
||||
() => {
|
||||
fetchToken(username, password, true).then(
|
||||
(token) => {
|
||||
api.defaults.headers.common.Authorization = `Bearer ${token}`;
|
||||
;
|
||||
Cookies.set('token', token)
|
||||
setApiAuthenticated(true)
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="xs">
|
||||
<CssBaseline />
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography component="h1" variant="h5">
|
||||
Sign up
|
||||
</Typography>
|
||||
<Box component="form" onSubmit={handleSubmit} noValidate sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="username"
|
||||
label="User Name"
|
||||
name="username"
|
||||
autoFocus
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
id="password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
>
|
||||
Sign Up
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
80
src/users.tsx
Normal file
80
src/users.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { TableRow, TableCell, TableContainer, TableHead, Table, Button, Paper, TableBody, Chip, Modal, Box } from "@mui/material"
|
||||
import React, { useContext, Dispatch, useState, useEffect, Context, createContext } from "react"
|
||||
import Form from "@rjsf/mui"
|
||||
import { apiAuthenticatedContext, useApiDoc, api, ActionInfo, useActions, ActionGroup, actionIdentifierContext, ActionItem, DataTable, useAction } from "./common"
|
||||
import validator from '@rjsf/validator-ajv8';
|
||||
import { User } from './interfaces'
|
||||
|
||||
const UserActionsContext: Context<ActionInfo[]> = createContext([] as ActionInfo[])
|
||||
|
||||
|
||||
function UserItem(p: { user: User }) {
|
||||
const user = p.user;
|
||||
const userActions = useContext(UserActionsContext)
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>{user.username}</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{user.permissions.map((value, index, array) => { return <Chip label={value} /> })}</TableCell>
|
||||
<TableCell><ActionGroup actions={userActions} identifierSubstring="username" /></TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
|
||||
export function UsersPage({ }) {
|
||||
const [apiAuthenticated, setApiAuthenticated] = useContext(apiAuthenticatedContext)
|
||||
const [users, setUsers]: [User[], Dispatch<User[]>] = useState([] as User[])
|
||||
|
||||
const action: ActionInfo|undefined = useAction({path: '/users', method: 'post'})
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiAuthenticated) {
|
||||
return
|
||||
}
|
||||
api.get('/users').then((response) => { setUsers(response.data) }).catch(
|
||||
(error) => {
|
||||
console.log('Failed to get servers: ' + error);
|
||||
if (error.response) {
|
||||
if (error.response.status === 401) {
|
||||
setApiAuthenticated(false)
|
||||
}
|
||||
else if (error.response.status === 403) {
|
||||
setApiAuthenticated(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const interval = setInterval(() => {
|
||||
api.get('/users').then((response) => { setUsers(response.data) }).catch(
|
||||
(error) => {
|
||||
console.log('Failed to get users: ' + error);
|
||||
if (error.response) {
|
||||
if (error.response.status === 401) {
|
||||
setApiAuthenticated(false)
|
||||
}
|
||||
else if (error.response.status === 403) {
|
||||
setApiAuthenticated(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
}, 5000
|
||||
);
|
||||
return () => { clearInterval(interval) }
|
||||
}, [apiAuthenticated])
|
||||
|
||||
let userComponents = []
|
||||
|
||||
for (let user of users) {
|
||||
userComponents.push(<actionIdentifierContext.Provider value={user.username}><UserItem user={user} /></actionIdentifierContext.Provider>)
|
||||
}
|
||||
|
||||
return <DataTable headers={['Username', 'Email', 'Permissions', 'Actions']} actionInfo={action}>
|
||||
<UserActionsContext.Provider value={useActions(api, '/users/{username}')}>
|
||||
{userComponents}
|
||||
</UserActionsContext.Provider>
|
||||
</DataTable>
|
||||
}
|
3
startup.sh
Normal file
3
startup.sh
Normal file
@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env /bin/sh
|
||||
echo $REACT_API_SCHEME://$REACT_API_URL
|
||||
/usr/bin/env npm run build && /usr/bin/env npx -y serve -s build
|
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user