added terminal resize
This commit is contained in:
parent
340dd92e8a
commit
2dcf21d62e
36096
package-lock.json
generated
36096
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
118
package.json
118
package.json
@ -1,59 +1,59 @@
|
||||
{
|
||||
"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",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
{
|
||||
"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",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
1392
src/actions.tsx
1392
src/actions.tsx
File diff suppressed because it is too large
Load Diff
856
src/common.tsx
856
src/common.tsx
@ -1,428 +1,428 @@
|
||||
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 { User } from "./interfaces";
|
||||
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';
|
||||
import { Permission } from "./actions";
|
||||
import TerminalComponent from "./terminal";
|
||||
|
||||
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 terminalModalStyle = {
|
||||
position: 'absolute' as 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '70%',
|
||||
bgcolor: 'background.paper',
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
padding: 10,
|
||||
}
|
||||
|
||||
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}`
|
||||
|
||||
axios.defaults.withCredentials = true
|
||||
|
||||
export const api: AxiosInstance = axios.create({
|
||||
baseURL: API_URL,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export function ApiWrapper(p: { children: ReactNode}) {
|
||||
const {children} = p
|
||||
const token = Cookies.get('auth')
|
||||
|
||||
const [apiAuthenticated, setApiAuthenticated] = useState(Boolean(token))
|
||||
if (!apiAuthenticated) {
|
||||
Cookies.remove('auth')
|
||||
}
|
||||
const path = useLocation()
|
||||
return (<apiAuthenticatedContext.Provider value={[apiAuthenticated, setApiAuthenticated]}>
|
||||
{children}
|
||||
{!apiAuthenticated && (path.pathname !== '/login' && path.pathname !== '/signup') && <Navigate to='/login' />}
|
||||
</apiAuthenticatedContext.Provider>)
|
||||
}
|
||||
|
||||
export interface ActionInfo {
|
||||
name: string
|
||||
requestType: 'post' | 'get' | 'delete' | 'patch'
|
||||
endpoint: string
|
||||
args: {}
|
||||
permissions?: number
|
||||
response_action?: 'Ignore' | 'Browse' | 'Terminal'
|
||||
}
|
||||
|
||||
|
||||
interface Options{
|
||||
label: string
|
||||
const: string
|
||||
}
|
||||
|
||||
export const actionIdentifierContext: Context<string> = createContext('')
|
||||
|
||||
|
||||
function convertNumber(permissions: number): number[]{
|
||||
var arr: number[] = []
|
||||
if (permissions&Permission.Start){
|
||||
arr.push(Permission.Start)
|
||||
}
|
||||
if (permissions&Permission.Stop){
|
||||
arr.push(Permission.Stop)
|
||||
}
|
||||
if (permissions&Permission.Browse){
|
||||
arr.push(Permission.Browse)
|
||||
}
|
||||
if (permissions&Permission.RunCommand){
|
||||
arr.push(Permission.RunCommand)
|
||||
}
|
||||
if (permissions&Permission.Create){
|
||||
arr.push(Permission.Create)
|
||||
}
|
||||
if (permissions&Permission.Delete){
|
||||
arr.push(Permission.Delete)
|
||||
}
|
||||
if (permissions&Permission.Admin){
|
||||
arr.push(Permission.Admin)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
function CustomField(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, CheckboxesWidget} = registry.widgets
|
||||
|
||||
if (!schema.fetch_url){
|
||||
if (!schema.permissions){
|
||||
return <TextField onChange={(event)=>(props.onChange(event.target.value))} value={props.value} label={props.label}/>
|
||||
}
|
||||
|
||||
return <CheckboxesWidget
|
||||
{...newProps}
|
||||
onChange={(event)=>{
|
||||
props.onChange(event.reduce((partialSum: number, a: number) => (partialSum + a), 0))
|
||||
}
|
||||
}
|
||||
schema={{}}
|
||||
options={{
|
||||
enumOptions: [
|
||||
{label: 'Start', value: Permission.Start},
|
||||
{label: 'Stop', value: Permission.Stop},
|
||||
{label: 'Browse', value: Permission.Browse},
|
||||
{label: 'Delete', value: Permission.Delete},
|
||||
{label: 'Run Command', value: Permission.RunCommand},
|
||||
{label: 'Create', value: Permission.Create},
|
||||
{label: 'Admin', value: Permission.Admin},
|
||||
{label: 'Cloud', value: Permission.Cloud},
|
||||
]}} registry={registry} value={convertNumber(props.value)} />
|
||||
}
|
||||
|
||||
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 & Permission.Admin) === Permission.Admin
|
||||
if (isAdmin){
|
||||
return true
|
||||
}
|
||||
|
||||
if (!action.permissions){
|
||||
return true
|
||||
}
|
||||
|
||||
if ((action.permissions & user.Permissions) == action.permissions){
|
||||
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 [terminal, setTerminal] = useState(null as string|null);
|
||||
const [formData, setFormData]: [RJSFSchema, Dispatch<RJSFSchema>] = useState({})
|
||||
const url = p.action.endpoint.replaceAll(`{${identifierSubstring}}`, actionIdentifier)
|
||||
|
||||
|
||||
function handleSubmit() {
|
||||
let promise: Promise<AxiosResponse<any, any>>|null = null
|
||||
switch (p.action.requestType) {
|
||||
case 'post': {
|
||||
promise = api.post(url, formData)
|
||||
break
|
||||
}
|
||||
case 'patch':{
|
||||
promise = api.patch(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() } p.action.response_action == 'Terminal'?setTerminal(`ws${API_URL.slice("http".length)}${url}`):setForm(true) }} sx={p.sx}>{p.action.name}</Button >
|
||||
<Modal
|
||||
onClose={() => { setForm(false); setFormData({}); }}
|
||||
open={form}
|
||||
>
|
||||
<Box sx={formModalStyle}>
|
||||
<Form validator={validator} widgets={{TextWidget: CustomField}} schema={p.action.args} onChange={onFormChange} formData={formData} onSubmit={handleSubmit} />
|
||||
</Box>
|
||||
</Modal>
|
||||
<Modal
|
||||
onClose={() => { setTerminal(null); }}
|
||||
open={terminal != null}
|
||||
>
|
||||
<Box sx={terminalModalStyle}>
|
||||
<TerminalComponent websocket={terminal} />
|
||||
</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) => {
|
||||
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>
|
||||
}
|
||||
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 { User } from "./interfaces";
|
||||
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';
|
||||
import { Permission } from "./actions";
|
||||
import TerminalComponent from "./terminal";
|
||||
|
||||
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 terminalModalStyle = {
|
||||
position: 'absolute' as 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '70%',
|
||||
bgcolor: 'background.paper',
|
||||
p: 4,
|
||||
borderRadius: 3,
|
||||
padding: 10,
|
||||
}
|
||||
|
||||
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}`
|
||||
|
||||
axios.defaults.withCredentials = true
|
||||
|
||||
export const api: AxiosInstance = axios.create({
|
||||
baseURL: API_URL,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export function ApiWrapper(p: { children: ReactNode}) {
|
||||
const {children} = p
|
||||
const token = Cookies.get('auth')
|
||||
|
||||
const [apiAuthenticated, setApiAuthenticated] = useState(Boolean(token))
|
||||
if (!apiAuthenticated) {
|
||||
Cookies.remove('auth')
|
||||
}
|
||||
const path = useLocation()
|
||||
return (<apiAuthenticatedContext.Provider value={[apiAuthenticated, setApiAuthenticated]}>
|
||||
{children}
|
||||
{!apiAuthenticated && (path.pathname !== '/login' && path.pathname !== '/signup') && <Navigate to='/login' />}
|
||||
</apiAuthenticatedContext.Provider>)
|
||||
}
|
||||
|
||||
export interface ActionInfo {
|
||||
name: string
|
||||
requestType: 'post' | 'get' | 'delete' | 'patch'
|
||||
endpoint: string
|
||||
args: {}
|
||||
permissions?: number
|
||||
response_action?: 'Ignore' | 'Browse' | 'Terminal'
|
||||
}
|
||||
|
||||
|
||||
interface Options{
|
||||
label: string
|
||||
const: string
|
||||
}
|
||||
|
||||
export const actionIdentifierContext: Context<string> = createContext('')
|
||||
|
||||
|
||||
function convertNumber(permissions: number): number[]{
|
||||
var arr: number[] = []
|
||||
if (permissions&Permission.Start){
|
||||
arr.push(Permission.Start)
|
||||
}
|
||||
if (permissions&Permission.Stop){
|
||||
arr.push(Permission.Stop)
|
||||
}
|
||||
if (permissions&Permission.Browse){
|
||||
arr.push(Permission.Browse)
|
||||
}
|
||||
if (permissions&Permission.RunCommand){
|
||||
arr.push(Permission.RunCommand)
|
||||
}
|
||||
if (permissions&Permission.Create){
|
||||
arr.push(Permission.Create)
|
||||
}
|
||||
if (permissions&Permission.Delete){
|
||||
arr.push(Permission.Delete)
|
||||
}
|
||||
if (permissions&Permission.Admin){
|
||||
arr.push(Permission.Admin)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
function CustomField(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, CheckboxesWidget} = registry.widgets
|
||||
|
||||
if (!schema.fetch_url){
|
||||
if (!schema.permissions){
|
||||
return <TextField onChange={(event)=>(props.onChange(event.target.value))} value={props.value} label={props.label}/>
|
||||
}
|
||||
|
||||
return <CheckboxesWidget
|
||||
{...newProps}
|
||||
onChange={(event)=>{
|
||||
props.onChange(event.reduce((partialSum: number, a: number) => (partialSum + a), 0))
|
||||
}
|
||||
}
|
||||
schema={{}}
|
||||
options={{
|
||||
enumOptions: [
|
||||
{label: 'Start', value: Permission.Start},
|
||||
{label: 'Stop', value: Permission.Stop},
|
||||
{label: 'Browse', value: Permission.Browse},
|
||||
{label: 'Delete', value: Permission.Delete},
|
||||
{label: 'Run Command', value: Permission.RunCommand},
|
||||
{label: 'Create', value: Permission.Create},
|
||||
{label: 'Admin', value: Permission.Admin},
|
||||
{label: 'Cloud', value: Permission.Cloud},
|
||||
]}} registry={registry} value={convertNumber(props.value)} />
|
||||
}
|
||||
|
||||
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 & Permission.Admin) === Permission.Admin
|
||||
if (isAdmin){
|
||||
return true
|
||||
}
|
||||
|
||||
if (!action.permissions){
|
||||
return true
|
||||
}
|
||||
|
||||
if ((action.permissions & user.Permissions) == action.permissions){
|
||||
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 [terminal, setTerminal] = useState(null as string|null);
|
||||
const [formData, setFormData]: [RJSFSchema, Dispatch<RJSFSchema>] = useState({})
|
||||
const url = p.action.endpoint.replaceAll(`{${identifierSubstring}}`, actionIdentifier)
|
||||
|
||||
|
||||
function handleSubmit() {
|
||||
let promise: Promise<AxiosResponse<any, any>>|null = null
|
||||
switch (p.action.requestType) {
|
||||
case 'post': {
|
||||
promise = api.post(url, formData)
|
||||
break
|
||||
}
|
||||
case 'patch':{
|
||||
promise = api.patch(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() } p.action.response_action == 'Terminal'?setTerminal(`ws${API_URL.slice("http".length)}${url}`):setForm(true) }} sx={p.sx}>{p.action.name}</Button >
|
||||
<Modal
|
||||
onClose={() => { setForm(false); setFormData({}); }}
|
||||
open={form}
|
||||
>
|
||||
<Box sx={formModalStyle}>
|
||||
<Form validator={validator} widgets={{TextWidget: CustomField}} schema={p.action.args} onChange={onFormChange} formData={formData} onSubmit={handleSubmit} />
|
||||
</Box>
|
||||
</Modal>
|
||||
<Modal
|
||||
onClose={() => { setTerminal(null); }}
|
||||
open={terminal != null}
|
||||
>
|
||||
<Box sx={terminalModalStyle}>
|
||||
<TerminalComponent websocket={terminal} />
|
||||
</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) => {
|
||||
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>
|
||||
}
|
||||
|
131
src/terminal.tsx
131
src/terminal.tsx
@ -1,50 +1,81 @@
|
||||
// src/components/Terminal.tsx
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import 'xterm/css/xterm.css';
|
||||
|
||||
function TerminalComponent (p: {websocket: string|null}) {
|
||||
const {websocket} = p
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const terminal = useRef<Terminal | null>(null);
|
||||
const fitAddon = useRef<FitAddon | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (terminalRef.current) {
|
||||
terminal.current = new Terminal();
|
||||
fitAddon.current = new FitAddon();
|
||||
terminal.current.loadAddon(fitAddon.current);
|
||||
terminal.current.open(terminalRef.current);
|
||||
fitAddon.current.fit();
|
||||
if (websocket == null){
|
||||
|
||||
return ()=>{
|
||||
terminal.current?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
const socket = new WebSocket(websocket);
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
terminal.current?.write(JSON.parse(event.data));
|
||||
});
|
||||
|
||||
terminal.current.onData((data) => {
|
||||
socket.send(JSON.stringify({CommandType: 'insert', Arguments: [data]}));
|
||||
});
|
||||
|
||||
return () => {
|
||||
terminal.current?.dispose();
|
||||
socket.close();
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <div ref={terminalRef} style={{ width: '100%', height: '100%' }}></div>;
|
||||
};
|
||||
|
||||
export default TerminalComponent;
|
||||
// src/components/Terminal.tsx
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import 'xterm/css/xterm.css';
|
||||
|
||||
|
||||
const useTerminalResize = (terminalRef: React.RefObject<HTMLDivElement>, fitAddon: FitAddon) => {
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (fitAddon && terminalRef.current) {
|
||||
fitAddon.fit();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Fit the terminal initially
|
||||
handleResize();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [terminalRef, fitAddon]);
|
||||
};
|
||||
|
||||
|
||||
function TerminalComponent (p: {websocket: string|null}) {
|
||||
const {websocket} = p
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const terminal = useRef<Terminal | null>(null);
|
||||
const fitAddon = useRef<FitAddon | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (terminalRef.current) {
|
||||
terminal.current = new Terminal();
|
||||
fitAddon.current = new FitAddon();
|
||||
terminal.current.loadAddon(fitAddon.current);
|
||||
terminal.current.open(terminalRef.current);
|
||||
fitAddon.current.fit();
|
||||
if (websocket == null){
|
||||
|
||||
return ()=>{
|
||||
terminal.current?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
const socket = new WebSocket(websocket);
|
||||
|
||||
socket.send(JSON.stringify({CommandType: 'resize', Arguments: [terminal.current.cols, terminal.current.rows]}))
|
||||
|
||||
terminal.current.onResize(
|
||||
({cols, rows}, _)=>{
|
||||
socket.send(JSON.stringify({CommandType: 'resize', Arguments: [cols, rows]}))
|
||||
}
|
||||
)
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
terminal.current?.write(JSON.parse(event.data));
|
||||
});
|
||||
|
||||
terminal.current.onData((data) => {
|
||||
socket.send(JSON.stringify({CommandType: 'insert', Arguments: [data]}));
|
||||
});
|
||||
|
||||
return () => {
|
||||
terminal.current?.dispose();
|
||||
socket.close();
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
useTerminalResize(terminalRef, fitAddon.current!)
|
||||
|
||||
return <div ref={terminalRef} style={{ width: '100%', height: '100%' }}></div>;
|
||||
};
|
||||
|
||||
export default TerminalComponent;
|
||||
|
Loading…
x
Reference in New Issue
Block a user