added frontend docker

This commit is contained in:
ACoolName 2023-12-11 00:12:36 +02:00
parent 1bead994bd
commit da01815f27
28 changed files with 20994 additions and 0 deletions

23
.gitignore vendored Normal file
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
Dockerfile Normal file
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
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

57
package.json Normal file
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"
]
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

43
public/index.html Normal file
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>

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
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
public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

38
src/App.css Normal file
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
src/App.test.tsx Normal file
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
src/App.tsx Normal file
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
src/browsers.tsx Normal file
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
src/common.tsx Normal file
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
src/index.css Normal file
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
src/index.tsx Normal file
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
src/interfaces.tsx Normal file
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
src/login.tsx Normal file
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
src/logo.svg Normal file
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
src/react-app-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

15
src/reportWebVitals.ts Normal file
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
src/servers.tsx Normal file
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
src/setupTests.ts Normal file
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
src/signup.tsx Normal file
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
src/users.tsx Normal file
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
startup.sh Normal file
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
tsconfig.json Normal file
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"
]
}