Merge branch 'test' into 'main'

Test

See merge request server-manager/frontend!1
This commit is contained in:
Administrator
2023-12-22 15:46:03 +00:00
28 changed files with 20994 additions and 0 deletions
+23
View 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
View 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
View File
File diff suppressed because it is too large Load Diff
+57
View 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"
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

+43
View 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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

+25
View 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
View File
@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
+38
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File
@@ -0,0 +1 @@
/// <reference types="react-scripts" />
+15
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
]
}