commit
a16e626ede
190 changed files with 13691 additions and 2855 deletions
@ -0,0 +1,4 @@ |
||||
# dependencies |
||||
node_modules |
||||
|
||||
out/*LICENSE* |
@ -0,0 +1,6 @@ |
||||
{ |
||||
"endOfLine": "lf", |
||||
"singleQuote": true, |
||||
"tabWidth": 2, |
||||
"trailingComma": "all" |
||||
} |
@ -0,0 +1,6 @@ |
||||
MAINTAINERCLEANFILES = Makefile.in
|
||||
|
||||
strikeruiapidir = $(datarootdir)/striker-ui-api
|
||||
|
||||
dist_strikeruiapi_DATA = out/index.js
|
||||
|
@ -0,0 +1,18 @@ |
||||
const cors = require('cors'); |
||||
const express = require('express'); |
||||
const path = require('path'); |
||||
|
||||
const API_ROOT_PATH = require('./lib/consts/API_ROOT_PATH'); |
||||
|
||||
const echoRouter = require('./routes/echo'); |
||||
const filesRouter = require('./routes/files'); |
||||
|
||||
const app = express(); |
||||
|
||||
app.use(express.json()); |
||||
app.use(cors()); |
||||
|
||||
app.use(path.join(API_ROOT_PATH, 'echo'), echoRouter); |
||||
app.use(path.join(API_ROOT_PATH, 'files'), filesRouter); |
||||
|
||||
module.exports = app; |
@ -0,0 +1,7 @@ |
||||
const app = require('./app'); |
||||
|
||||
const SERVER_PORT = require('./lib/consts/SERVER_PORT'); |
||||
|
||||
app.listen(SERVER_PORT, () => { |
||||
console.log(`Listening on localhost:${SERVER_PORT}.`); |
||||
}); |
@ -0,0 +1,94 @@ |
||||
const { spawnSync } = require('child_process'); |
||||
|
||||
const SERVER_PATHS = require('./consts/SERVER_PATHS'); |
||||
|
||||
const execStrikerAccessDatabase = ( |
||||
args, |
||||
options = { |
||||
timeout: 10000, |
||||
encoding: 'utf-8', |
||||
}, |
||||
) => { |
||||
const { error, stdout, stderr } = spawnSync( |
||||
SERVER_PATHS.usr.sbin['striker-access-database'].self, |
||||
args, |
||||
options, |
||||
); |
||||
|
||||
if (error) { |
||||
throw error; |
||||
} |
||||
|
||||
if (stderr) { |
||||
throw new Error(stderr); |
||||
} |
||||
|
||||
let output; |
||||
|
||||
try { |
||||
output = JSON.parse(stdout); |
||||
} catch (stdoutParseError) { |
||||
output = stdout; |
||||
|
||||
console.warn( |
||||
`Failed to parse striker-access-database output [${output}]; error: [${stdoutParseError}]`, |
||||
); |
||||
} |
||||
|
||||
return { |
||||
stdout: output, |
||||
}; |
||||
}; |
||||
|
||||
const execDatabaseModuleSubroutine = (subName, subParams, options) => { |
||||
const args = ['--sub', subName]; |
||||
|
||||
if (subParams) { |
||||
args.push('--sub-params', JSON.stringify(subParams)); |
||||
} |
||||
|
||||
const { stdout } = execStrikerAccessDatabase(args, options); |
||||
|
||||
return { |
||||
stdout: stdout['sub_results'], |
||||
}; |
||||
}; |
||||
|
||||
const accessDB = { |
||||
dbJobAnvilSyncShared: ( |
||||
jobName, |
||||
jobData, |
||||
jobTitle, |
||||
jobDescription, |
||||
{ jobHostUUID } = { jobHostUUID: undefined }, |
||||
) => { |
||||
const subParams = { |
||||
file: __filename, |
||||
line: 0, |
||||
job_command: SERVER_PATHS.usr.sbin['anvil-sync-shared'].self, |
||||
job_data: jobData, |
||||
job_name: `storage::${jobName}`, |
||||
job_title: `job_${jobTitle}`, |
||||
job_description: `job_${jobDescription}`, |
||||
job_progress: 0, |
||||
}; |
||||
|
||||
if (jobHostUUID) { |
||||
subParams.job_host_uuid = jobHostUUID; |
||||
} |
||||
|
||||
console.log(JSON.stringify(subParams, null, 2)); |
||||
|
||||
return execDatabaseModuleSubroutine('insert_or_update_jobs', subParams) |
||||
.stdout; |
||||
}, |
||||
dbQuery: (query, options) => |
||||
execStrikerAccessDatabase(['--query', query], options), |
||||
dbSub: execDatabaseModuleSubroutine, |
||||
dbSubRefreshTimestamp: () => |
||||
execDatabaseModuleSubroutine('refresh_timestamp').stdout, |
||||
dbWrite: (query, options) => |
||||
execStrikerAccessDatabase(['--query', query, '--mode', 'write'], options), |
||||
}; |
||||
|
||||
module.exports = accessDB; |
@ -0,0 +1,3 @@ |
||||
const API_ROOT_PATH = '/api'; |
||||
|
||||
module.exports = API_ROOT_PATH; |
@ -0,0 +1,34 @@ |
||||
const path = require('path'); |
||||
|
||||
const SERVER_PATHS = { |
||||
mnt: { |
||||
shared: { |
||||
incoming: {}, |
||||
}, |
||||
}, |
||||
usr: { |
||||
sbin: { |
||||
'anvil-sync-shared': {}, |
||||
'striker-access-database': {}, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const generatePaths = ( |
||||
currentObject, |
||||
parents = path.parse(process.cwd()).root, |
||||
) => { |
||||
Object.keys(currentObject).forEach((pathKey) => { |
||||
const currentPath = path.join(parents, pathKey); |
||||
|
||||
currentObject[pathKey].self = currentPath; |
||||
|
||||
if (pathKey !== 'self') { |
||||
generatePaths(currentObject[pathKey], currentPath); |
||||
} |
||||
}); |
||||
}; |
||||
|
||||
generatePaths(SERVER_PATHS); |
||||
|
||||
module.exports = SERVER_PATHS; |
@ -0,0 +1,3 @@ |
||||
const SERVER_PORT = process.env.SERVER_PORT ?? 8080; |
||||
|
||||
module.exports = SERVER_PORT; |
@ -0,0 +1,29 @@ |
||||
const { dbQuery } = require('../../accessDB'); |
||||
|
||||
const buildGetFiles = (query) => (request, response) => { |
||||
console.log('Calling CLI script to get data.'); |
||||
|
||||
let queryStdout; |
||||
|
||||
try { |
||||
({ stdout: queryStdout } = dbQuery( |
||||
typeof query === 'function' ? query(request) : query, |
||||
)); |
||||
} catch (queryError) { |
||||
console.log(`Query error: ${queryError}`); |
||||
|
||||
response.status(500).send(); |
||||
} |
||||
|
||||
console.log( |
||||
`Query stdout (type=[${typeof queryStdout}]): ${JSON.stringify( |
||||
queryStdout, |
||||
null, |
||||
2, |
||||
)}`,
|
||||
); |
||||
|
||||
response.json(queryStdout); |
||||
}; |
||||
|
||||
module.exports = buildGetFiles; |
@ -0,0 +1,25 @@ |
||||
const buildGetFiles = require('./buildGetFiles'); |
||||
|
||||
const getFileDetail = buildGetFiles( |
||||
(request) => |
||||
`SELECT
|
||||
fil.file_uuid, |
||||
fil.file_name, |
||||
fil.file_size, |
||||
fil.file_type, |
||||
fil.file_md5sum, |
||||
fil_loc.file_location_uuid, |
||||
fil_loc.file_location_active, |
||||
anv.anvil_uuid, |
||||
anv.anvil_name, |
||||
anv.anvil_description |
||||
FROM files AS fil |
||||
JOIN file_locations AS fil_loc |
||||
ON fil.file_uuid = fil_loc.file_location_file_uuid |
||||
JOIN anvils AS anv |
||||
ON fil_loc.file_location_anvil_uuid = anv.anvil_uuid |
||||
WHERE fil.file_uuid = '${request.params.fileUUID}' |
||||
AND fil.file_type != 'DELETED';`,
|
||||
); |
||||
|
||||
module.exports = getFileDetail; |
@ -0,0 +1,13 @@ |
||||
const buildGetFiles = require('./buildGetFiles'); |
||||
|
||||
const getFilesOverview = buildGetFiles(` |
||||
SELECT |
||||
file_uuid, |
||||
file_name, |
||||
file_size, |
||||
file_type, |
||||
file_md5sum |
||||
FROM files |
||||
WHERE file_type != 'DELETED';`);
|
||||
|
||||
module.exports = getFilesOverview; |
@ -0,0 +1,16 @@ |
||||
const multer = require('multer'); |
||||
|
||||
const SERVER_PATHS = require('../lib/consts/SERVER_PATHS'); |
||||
|
||||
const storage = multer.diskStorage({ |
||||
destination: (request, file, callback) => { |
||||
callback(null, SERVER_PATHS.mnt.shared.incoming.self); |
||||
}, |
||||
filename: (request, file, callback) => { |
||||
callback(null, file.originalname); |
||||
}, |
||||
}); |
||||
|
||||
const uploadSharedFiles = multer({ storage }); |
||||
|
||||
module.exports = uploadSharedFiles; |
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,26 @@ |
||||
{ |
||||
"name": "striker-ui-api", |
||||
"version": "0.1.0", |
||||
"description": "API for striker-ui", |
||||
"scripts": { |
||||
"build": "webpack", |
||||
"build:clean": "rm -rf out", |
||||
"dev": "node index.js", |
||||
"rebuild": "npm run build:clean && npm run build", |
||||
"start": "npm run rebuild && node out/index.js", |
||||
"style:fix": "prettier --write *" |
||||
}, |
||||
"dependencies": { |
||||
"cors": "^2.8.5", |
||||
"express": "^4.17.1", |
||||
"multer": "^1.4.4" |
||||
}, |
||||
"devDependencies": { |
||||
"@babel/core": "^7.17.8", |
||||
"@babel/preset-env": "^7.16.11", |
||||
"babel-loader": "^8.2.3", |
||||
"prettier": "^2.5.0", |
||||
"webpack": "^5.70.0", |
||||
"webpack-cli": "^4.9.2" |
||||
} |
||||
} |
@ -0,0 +1,17 @@ |
||||
const express = require('express'); |
||||
|
||||
const router = express.Router(); |
||||
|
||||
router |
||||
.get('/', (request, response) => { |
||||
response.status(200).send({ message: 'Empty echo.' }); |
||||
}) |
||||
.post('/', (request, response) => { |
||||
console.log('echo:post', JSON.stringify(request.body, undefined, 2)); |
||||
|
||||
const message = request.body.message ?? 'No message.'; |
||||
|
||||
response.status(200).send({ message }); |
||||
}); |
||||
|
||||
module.exports = router; |
@ -0,0 +1,194 @@ |
||||
const express = require('express'); |
||||
|
||||
const { |
||||
dbJobAnvilSyncShared, |
||||
dbQuery, |
||||
dbSubRefreshTimestamp, |
||||
dbWrite, |
||||
} = require('../lib/accessDB'); |
||||
const getFilesOverview = require('../lib/request_handlers/files/getFilesOverview'); |
||||
const getFileDetail = require('../lib/request_handlers/files/getFileDetail'); |
||||
const uploadSharedFiles = require('../middlewares/uploadSharedFiles'); |
||||
|
||||
const router = express.Router(); |
||||
|
||||
router |
||||
.delete('/:fileUUID', (request, response) => { |
||||
const { fileUUID } = request.params; |
||||
const FILE_TYPE_DELETED = 'DELETED'; |
||||
|
||||
const [[oldFileType]] = dbQuery( |
||||
`SELECT file_type FROM files WHERE file_uuid = '${fileUUID}';`, |
||||
).stdout; |
||||
|
||||
if (oldFileType !== FILE_TYPE_DELETED) { |
||||
dbWrite( |
||||
`UPDATE files
|
||||
SET |
||||
file_type = '${FILE_TYPE_DELETED}', |
||||
modified_date = '${dbSubRefreshTimestamp()}' |
||||
WHERE file_uuid = '${fileUUID}';`,
|
||||
).stdout; |
||||
|
||||
dbJobAnvilSyncShared('purge', `file_uuid=${fileUUID}`, '0136', '0137', { |
||||
jobHostUUID: 'all', |
||||
}); |
||||
} |
||||
|
||||
response.status(204).send(); |
||||
}) |
||||
.get('/', getFilesOverview) |
||||
.get('/:fileUUID', getFileDetail) |
||||
.post('/', uploadSharedFiles.single('file'), ({ file, body }, response) => { |
||||
console.log('Receiving shared file.'); |
||||
|
||||
if (file) { |
||||
console.log(`file: ${JSON.stringify(file, null, 2)}`); |
||||
console.log(`body: ${JSON.stringify(body, null, 2)}`); |
||||
|
||||
dbJobAnvilSyncShared( |
||||
'move_incoming', |
||||
`file=${file.path}`, |
||||
'0132', |
||||
'0133', |
||||
); |
||||
|
||||
response.status(200).send(); |
||||
} |
||||
}) |
||||
.put('/:fileUUID', (request, response) => { |
||||
console.log('Begin edit single file.'); |
||||
console.dir(request.body); |
||||
|
||||
const { fileUUID } = request.params; |
||||
const { fileName, fileLocations, fileType } = request.body; |
||||
const anvilSyncSharedFunctions = []; |
||||
|
||||
let query = ''; |
||||
|
||||
if (fileName) { |
||||
const [[oldFileName]] = dbQuery( |
||||
`SELECT file_name FROM files WHERE file_uuid = '${fileUUID}';`, |
||||
).stdout; |
||||
console.log(`oldFileName=[${oldFileName}],newFileName=[${fileName}]`); |
||||
|
||||
if (fileName !== oldFileName) { |
||||
query += ` |
||||
UPDATE files |
||||
SET |
||||
file_name = '${fileName}', |
||||
modified_date = '${dbSubRefreshTimestamp()}' |
||||
WHERE file_uuid = '${fileUUID}';`;
|
||||
|
||||
anvilSyncSharedFunctions.push(() => |
||||
dbJobAnvilSyncShared( |
||||
'rename', |
||||
`file_uuid=${fileUUID}\nold_name=${oldFileName}\nnew_name=${fileName}`, |
||||
'0138', |
||||
'0139', |
||||
{ jobHostUUID: 'all' }, |
||||
), |
||||
); |
||||
} |
||||
} |
||||
|
||||
if (fileType) { |
||||
query += ` |
||||
UPDATE files |
||||
SET |
||||
file_type = '${fileType}', |
||||
modified_date = '${dbSubRefreshTimestamp()}' |
||||
WHERE file_uuid = '${fileUUID}';`;
|
||||
|
||||
anvilSyncSharedFunctions.push(() => |
||||
dbJobAnvilSyncShared( |
||||
'check_mode', |
||||
`file_uuid=${fileUUID}`, |
||||
'0143', |
||||
'0144', |
||||
{ jobHostUUID: 'all' }, |
||||
), |
||||
); |
||||
} |
||||
|
||||
if (fileLocations) { |
||||
fileLocations.forEach(({ fileLocationUUID, isFileLocationActive }) => { |
||||
let fileLocationActive = 0; |
||||
let jobName = 'purge'; |
||||
let jobTitle = '0136'; |
||||
let jobDescription = '0137'; |
||||
|
||||
if (isFileLocationActive) { |
||||
fileLocationActive = 1; |
||||
jobName = 'pull_file'; |
||||
jobTitle = '0132'; |
||||
jobDescription = '0133'; |
||||
} |
||||
|
||||
query += ` |
||||
UPDATE file_locations |
||||
SET |
||||
file_location_active = '${fileLocationActive}', |
||||
modified_date = '${dbSubRefreshTimestamp()}' |
||||
WHERE file_location_uuid = '${fileLocationUUID}';`;
|
||||
|
||||
const targetHosts = dbQuery( |
||||
`SELECT
|
||||
anv.anvil_node1_host_uuid, |
||||
anv.anvil_node2_host_uuid, |
||||
anv.anvil_dr1_host_uuid |
||||
FROM anvils AS anv |
||||
JOIN file_locations AS fil_loc |
||||
ON anv.anvil_uuid = fil_loc.file_location_anvil_uuid |
||||
WHERE fil_loc.file_location_uuid = '${fileLocationUUID}';`,
|
||||
).stdout; |
||||
|
||||
targetHosts.flat().forEach((hostUUID) => { |
||||
if (hostUUID) { |
||||
anvilSyncSharedFunctions.push(() => |
||||
dbJobAnvilSyncShared( |
||||
jobName, |
||||
`file_uuid=${fileUUID}`, |
||||
jobTitle, |
||||
jobDescription, |
||||
{ jobHostUUID: hostUUID }, |
||||
), |
||||
); |
||||
} |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
console.log(`Query (type=[${typeof query}]): [${query}]`); |
||||
|
||||
let queryStdout; |
||||
|
||||
try { |
||||
({ stdout: queryStdout } = dbWrite(query)); |
||||
} catch (queryError) { |
||||
console.log(`Query error: ${queryError}`); |
||||
|
||||
response.status(500).send(); |
||||
} |
||||
|
||||
console.log( |
||||
`Query stdout (type=[${typeof queryStdout}]): ${JSON.stringify( |
||||
queryStdout, |
||||
null, |
||||
2, |
||||
)}`,
|
||||
); |
||||
anvilSyncSharedFunctions.forEach((fn, index) => { |
||||
console.log( |
||||
`Anvil sync shared [${index}] output: [${JSON.stringify( |
||||
fn(), |
||||
null, |
||||
2, |
||||
)}]`,
|
||||
); |
||||
}); |
||||
|
||||
response.status(200).send(queryStdout); |
||||
}); |
||||
|
||||
module.exports = router; |
@ -0,0 +1,29 @@ |
||||
const path = require('path'); |
||||
|
||||
module.exports = { |
||||
entry: './index.js', |
||||
mode: 'production', |
||||
module: { |
||||
rules: [ |
||||
{ |
||||
exclude: /node_modules/, |
||||
test: /\.m?js$/, |
||||
use: { |
||||
loader: 'babel-loader', |
||||
options: { |
||||
presets: ['@babel/preset-env'], |
||||
}, |
||||
}, |
||||
}, |
||||
], |
||||
}, |
||||
optimization: { |
||||
minimize: true, |
||||
}, |
||||
output: { |
||||
path: path.resolve(__dirname, 'out'), |
||||
filename: 'index.js', |
||||
}, |
||||
stats: 'detailed', |
||||
target: 'node10', |
||||
}; |
@ -1,4 +1,4 @@ |
||||
{ |
||||
"!(out/**/*)*.{js,jsx,ts,tsx}": "npm run lint -- --fix", |
||||
"*.{json,md}": "prettier --write" |
||||
"!(out/**/*)*.{js,jsx,ts,tsx}": "npm run eslint:base -- --fix", |
||||
"!(out/**/*)*.{js,json,jsx,md,ts,tsx}": "prettier --write" |
||||
} |
||||
|
@ -1,97 +1,105 @@ |
||||
import { Divider, Drawer, List, ListItem, Box } from '@material-ui/core'; |
||||
import { makeStyles, createStyles } from '@material-ui/core/styles'; |
||||
import DashboardIcon from '@material-ui/icons/Dashboard'; |
||||
import { Box, Divider, Drawer, List, ListItem } from '@mui/material'; |
||||
import { styled } from '@mui/material/styles'; |
||||
import DashboardIcon from '@mui/icons-material/Dashboard'; |
||||
import { Dispatch, SetStateAction } from 'react'; |
||||
import { BodyText, HeaderText } from './Text'; |
||||
import { ICONS, ICON_SIZE } from '../lib/consts/ICONS'; |
||||
import { DIVIDER, GREY } from '../lib/consts/DEFAULT_THEME'; |
||||
|
||||
const PREFIX = 'AnvilDrawer'; |
||||
|
||||
const classes = { |
||||
list: `${PREFIX}-list`, |
||||
divider: `${PREFIX}-divider`, |
||||
text: `${PREFIX}-text`, |
||||
dashboardButton: `${PREFIX}-dashboardButton`, |
||||
dashboardIcon: `${PREFIX}-dashboardIcon`, |
||||
}; |
||||
|
||||
const StyledDrawer = styled(Drawer)(() => ({ |
||||
[`& .${classes.list}`]: { |
||||
width: '200px', |
||||
}, |
||||
|
||||
[`& .${classes.divider}`]: { |
||||
backgroundColor: DIVIDER, |
||||
}, |
||||
|
||||
[`& .${classes.text}`]: { |
||||
paddingTop: '.5em', |
||||
paddingLeft: '1.5em', |
||||
}, |
||||
|
||||
[`& .${classes.dashboardButton}`]: { |
||||
paddingLeft: '.1em', |
||||
}, |
||||
|
||||
[`& .${classes.dashboardIcon}`]: { |
||||
fontSize: '2.3em', |
||||
color: GREY, |
||||
}, |
||||
})); |
||||
|
||||
interface DrawerProps { |
||||
open: boolean; |
||||
setOpen: Dispatch<SetStateAction<boolean>>; |
||||
} |
||||
|
||||
const useStyles = makeStyles(() => |
||||
createStyles({ |
||||
list: { |
||||
width: '200px', |
||||
}, |
||||
divider: { |
||||
background: DIVIDER, |
||||
}, |
||||
text: { |
||||
paddingTop: '.5em', |
||||
paddingLeft: '1.5em', |
||||
}, |
||||
dashboardButton: { |
||||
paddingLeft: '.1em', |
||||
}, |
||||
dashboardIcon: { |
||||
fontSize: '2.3em', |
||||
color: GREY, |
||||
}, |
||||
}), |
||||
); |
||||
|
||||
const AnvilDrawer = ({ open, setOpen }: DrawerProps): JSX.Element => { |
||||
const classes = useStyles(); |
||||
|
||||
return ( |
||||
<Drawer |
||||
BackdropProps={{ invisible: true }} |
||||
anchor="left" |
||||
open={open} |
||||
onClose={() => setOpen(!open)} |
||||
> |
||||
<div role="presentation"> |
||||
<List className={classes.list}> |
||||
<ListItem button> |
||||
<HeaderText text="Admin" /> |
||||
</ListItem> |
||||
<Divider className={classes.divider} /> |
||||
<ListItem button component="a" href="/index.html"> |
||||
<Box display="flex" flexDirection="row" width="100%"> |
||||
<Box className={classes.dashboardButton}> |
||||
<DashboardIcon className={classes.dashboardIcon} /> |
||||
</Box> |
||||
<Box flexGrow={1} className={classes.text}> |
||||
<BodyText text="Dashboard" /> |
||||
</Box> |
||||
const AnvilDrawer = ({ open, setOpen }: DrawerProps): JSX.Element => ( |
||||
<StyledDrawer |
||||
BackdropProps={{ invisible: true }} |
||||
anchor="left" |
||||
open={open} |
||||
onClose={() => setOpen(!open)} |
||||
> |
||||
<div role="presentation"> |
||||
<List className={classes.list}> |
||||
<ListItem button> |
||||
<HeaderText text="Admin" /> |
||||
</ListItem> |
||||
<Divider className={classes.divider} /> |
||||
<ListItem button component="a" href="/index.html"> |
||||
<Box display="flex" flexDirection="row" width="100%"> |
||||
<Box className={classes.dashboardButton}> |
||||
<DashboardIcon className={classes.dashboardIcon} /> |
||||
</Box> |
||||
<Box flexGrow={1} className={classes.text}> |
||||
<BodyText text="Dashboard" /> |
||||
</Box> |
||||
</ListItem> |
||||
{ICONS.map( |
||||
(icon): JSX.Element => ( |
||||
<ListItem |
||||
button |
||||
key={icon.image} |
||||
component="a" |
||||
href={ |
||||
icon.uri.search(/^https?:/) !== -1 |
||||
? icon.uri |
||||
: `${process.env.NEXT_PUBLIC_API_URL}${icon.uri}` |
||||
} |
||||
> |
||||
<Box display="flex" flexDirection="row" width="100%"> |
||||
<Box> |
||||
<img |
||||
alt="" |
||||
key="icon" |
||||
src={icon.image} |
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...ICON_SIZE} |
||||
/> |
||||
</Box> |
||||
<Box flexGrow={1} className={classes.text}> |
||||
<BodyText text={icon.text} /> |
||||
</Box> |
||||
</Box> |
||||
</ListItem> |
||||
{ICONS.map( |
||||
(icon): JSX.Element => ( |
||||
<ListItem |
||||
button |
||||
key={icon.image} |
||||
component="a" |
||||
href={ |
||||
icon.uri.search(/^https?:/) !== -1 |
||||
? icon.uri |
||||
: `${process.env.NEXT_PUBLIC_API_URL}${icon.uri}` |
||||
} |
||||
> |
||||
<Box display="flex" flexDirection="row" width="100%"> |
||||
<Box> |
||||
<img |
||||
alt="" |
||||
key="icon" |
||||
src={icon.image} |
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...ICON_SIZE} |
||||
/> |
||||
</Box> |
||||
</ListItem> |
||||
), |
||||
)} |
||||
</List> |
||||
</div> |
||||
</Drawer> |
||||
); |
||||
}; |
||||
<Box flexGrow={1} className={classes.text}> |
||||
<BodyText text={icon.text} /> |
||||
</Box> |
||||
</Box> |
||||
</ListItem> |
||||
), |
||||
)} |
||||
</List> |
||||
</div> |
||||
</StyledDrawer> |
||||
); |
||||
|
||||
export default AnvilDrawer; |
||||
|
@ -1,15 +1,11 @@ |
||||
import { BodyText } from '../Text'; |
||||
import anvilState from '../../lib/consts/ANVILS'; |
||||
|
||||
const Anvil = ({ anvil }: { anvil: AnvilListItem }): JSX.Element => { |
||||
return ( |
||||
<> |
||||
<BodyText text={anvil.anvil_name} /> |
||||
<BodyText |
||||
text={anvilState.get(anvil.anvil_state) || 'State unavailable'} |
||||
/> |
||||
</> |
||||
); |
||||
}; |
||||
const Anvil = ({ anvil }: { anvil: AnvilListItem }): JSX.Element => ( |
||||
<> |
||||
<BodyText text={anvil.anvil_name} /> |
||||
<BodyText text={anvilState.get(anvil.anvil_state) || 'State unavailable'} /> |
||||
</> |
||||
); |
||||
|
||||
export default Anvil; |
||||
|
@ -1,61 +1,65 @@ |
||||
import { makeStyles, withStyles } from '@material-ui/core/styles'; |
||||
import { LinearProgress } from '@material-ui/core'; |
||||
import { LinearProgress } from '@mui/material'; |
||||
import { styled } from '@mui/material/styles'; |
||||
|
||||
import { |
||||
PURPLE, |
||||
RED, |
||||
BLUE, |
||||
PANEL_BACKGROUND, |
||||
BORDER_RADIUS, |
||||
} from '../../lib/consts/DEFAULT_THEME'; |
||||
import BorderLinearProgress from './BorderLinearProgress'; |
||||
|
||||
const breakpointWarning = 70; |
||||
const breakpointAlert = 90; |
||||
const PREFIX = 'AllocationBar'; |
||||
|
||||
const BorderLinearProgress = withStyles({ |
||||
root: { |
||||
height: '1em', |
||||
borderRadius: BORDER_RADIUS, |
||||
}, |
||||
colorPrimary: { |
||||
backgroundColor: PANEL_BACKGROUND, |
||||
}, |
||||
bar: { |
||||
borderRadius: BORDER_RADIUS, |
||||
}, |
||||
})(LinearProgress); |
||||
const classes = { |
||||
barOk: `${PREFIX}-barOk`, |
||||
barWarning: `${PREFIX}-barWarning`, |
||||
barAlert: `${PREFIX}-barAlert`, |
||||
underline: `${PREFIX}-underline`, |
||||
}; |
||||
|
||||
const useStyles = makeStyles(() => ({ |
||||
barOk: { |
||||
const StyledDiv = styled('div')(() => ({ |
||||
[`& .${classes.barOk}`]: { |
||||
backgroundColor: BLUE, |
||||
}, |
||||
barWarning: { |
||||
|
||||
[`& .${classes.barWarning}`]: { |
||||
backgroundColor: PURPLE, |
||||
}, |
||||
barAlert: { |
||||
|
||||
[`& .${classes.barAlert}`]: { |
||||
backgroundColor: RED, |
||||
}, |
||||
|
||||
[`& .${classes.underline}`]: { |
||||
borderRadius: BORDER_RADIUS, |
||||
}, |
||||
})); |
||||
|
||||
const AllocationBar = ({ allocated }: { allocated: number }): JSX.Element => { |
||||
const classes = useStyles(); |
||||
return ( |
||||
<> |
||||
<BorderLinearProgress |
||||
classes={{ |
||||
bar: |
||||
/* eslint-disable no-nested-ternary */ |
||||
allocated > breakpointWarning |
||||
? allocated > breakpointAlert |
||||
? classes.barAlert |
||||
: classes.barWarning |
||||
: classes.barOk, |
||||
}} |
||||
variant="determinate" |
||||
value={allocated} |
||||
/> |
||||
<LinearProgress variant="determinate" value={0} /> |
||||
</> |
||||
); |
||||
}; |
||||
const breakpointWarning = 70; |
||||
const breakpointAlert = 90; |
||||
|
||||
const AllocationBar = ({ allocated }: { allocated: number }): JSX.Element => ( |
||||
<StyledDiv> |
||||
<BorderLinearProgress |
||||
classes={{ |
||||
bar: |
||||
/* eslint-disable no-nested-ternary */ |
||||
allocated > breakpointWarning |
||||
? allocated > breakpointAlert |
||||
? classes.barAlert |
||||
: classes.barWarning |
||||
: classes.barOk, |
||||
}} |
||||
variant="determinate" |
||||
value={allocated} |
||||
/> |
||||
<LinearProgress |
||||
className={classes.underline} |
||||
variant="determinate" |
||||
value={0} |
||||
/> |
||||
</StyledDiv> |
||||
); |
||||
|
||||
export default AllocationBar; |
||||
|
@ -0,0 +1,14 @@ |
||||
import { LinearProgress } from '@mui/material'; |
||||
import { styled } from '@mui/material/styles'; |
||||
import { |
||||
PANEL_BACKGROUND, |
||||
BORDER_RADIUS, |
||||
} from '../../lib/consts/DEFAULT_THEME'; |
||||
|
||||
const BorderLinearProgress = styled(LinearProgress)({ |
||||
height: '1em', |
||||
borderRadius: BORDER_RADIUS, |
||||
backgroundColor: PANEL_BACKGROUND, |
||||
}); |
||||
|
||||
export default BorderLinearProgress; |
@ -1,57 +1,55 @@ |
||||
import { makeStyles, withStyles } from '@material-ui/core/styles'; |
||||
import { LinearProgress } from '@material-ui/core'; |
||||
import { |
||||
PURPLE, |
||||
BLUE, |
||||
PANEL_BACKGROUND, |
||||
BORDER_RADIUS, |
||||
} from '../../lib/consts/DEFAULT_THEME'; |
||||
import { LinearProgress } from '@mui/material'; |
||||
import { styled } from '@mui/material/styles'; |
||||
|
||||
const completed = 100; |
||||
import { PURPLE, BLUE, BORDER_RADIUS } from '../../lib/consts/DEFAULT_THEME'; |
||||
import BorderLinearProgress from './BorderLinearProgress'; |
||||
|
||||
const BorderLinearProgress = withStyles({ |
||||
root: { |
||||
height: '1em', |
||||
borderRadius: BORDER_RADIUS, |
||||
}, |
||||
colorPrimary: { |
||||
backgroundColor: PANEL_BACKGROUND, |
||||
}, |
||||
bar: { |
||||
borderRadius: BORDER_RADIUS, |
||||
}, |
||||
})(LinearProgress); |
||||
const PREFIX = 'ProgressBar'; |
||||
|
||||
const classes = { |
||||
barOk: `${PREFIX}-barOk`, |
||||
barInProgress: `${PREFIX}-barInProgress`, |
||||
underline: `${PREFIX}-underline`, |
||||
}; |
||||
|
||||
const useStyles = makeStyles(() => ({ |
||||
barOk: { |
||||
const StyledDiv = styled('div')(() => ({ |
||||
[`& .${classes.barOk}`]: { |
||||
backgroundColor: BLUE, |
||||
}, |
||||
barInProgress: { |
||||
|
||||
[`& .${classes.barInProgress}`]: { |
||||
backgroundColor: PURPLE, |
||||
}, |
||||
|
||||
[`& .${classes.underline}`]: { |
||||
borderRadius: BORDER_RADIUS, |
||||
}, |
||||
})); |
||||
|
||||
const completed = 100; |
||||
|
||||
const ProgressBar = ({ |
||||
progressPercentage, |
||||
}: { |
||||
progressPercentage: number; |
||||
}): JSX.Element => { |
||||
const classes = useStyles(); |
||||
return ( |
||||
<> |
||||
<BorderLinearProgress |
||||
classes={{ |
||||
bar: |
||||
progressPercentage < completed |
||||
? classes.barInProgress |
||||
: classes.barOk, |
||||
}} |
||||
variant="determinate" |
||||
value={progressPercentage} |
||||
/> |
||||
<LinearProgress variant="determinate" value={0} /> |
||||
</> |
||||
); |
||||
}; |
||||
}): JSX.Element => ( |
||||
<StyledDiv> |
||||
<BorderLinearProgress |
||||
classes={{ |
||||
bar: |
||||
progressPercentage < completed |
||||
? classes.barInProgress |
||||
: classes.barOk, |
||||
}} |
||||
variant="determinate" |
||||
value={progressPercentage} |
||||
/> |
||||
<LinearProgress |
||||
className={classes.underline} |
||||
variant="determinate" |
||||
value={0} |
||||
/> |
||||
</StyledDiv> |
||||
); |
||||
|
||||
export default ProgressBar; |
||||
|
@ -0,0 +1,68 @@ |
||||
import { MouseEventHandler } from 'react'; |
||||
import { Box, ButtonProps, Dialog, DialogProps } from '@mui/material'; |
||||
|
||||
import ContainedButton from './ContainedButton'; |
||||
import { Panel, PanelHeader } from './Panels'; |
||||
import { BodyText, HeaderText } from './Text'; |
||||
|
||||
type ConfirmDialogProps = { |
||||
actionCancelText?: string; |
||||
actionProceedText: string; |
||||
contentText: string; |
||||
dialogProps: DialogProps; |
||||
onCancel: MouseEventHandler<HTMLButtonElement>; |
||||
onProceed: MouseEventHandler<HTMLButtonElement>; |
||||
proceedButtonProps?: ButtonProps; |
||||
titleText: string; |
||||
}; |
||||
|
||||
const CONFIRM_DIALOG_DEFAULT_PROPS = { |
||||
actionCancelText: 'Cancel', |
||||
proceedButtonProps: { sx: undefined }, |
||||
}; |
||||
|
||||
const ConfirmDialog = ( |
||||
{ |
||||
actionCancelText, |
||||
actionProceedText, |
||||
contentText, |
||||
dialogProps: { open }, |
||||
onCancel, |
||||
onProceed, |
||||
proceedButtonProps, |
||||
titleText, |
||||
}: ConfirmDialogProps = CONFIRM_DIALOG_DEFAULT_PROPS as ConfirmDialogProps, |
||||
): JSX.Element => { |
||||
const { sx: proceedButtonSx } = |
||||
proceedButtonProps ?? CONFIRM_DIALOG_DEFAULT_PROPS.proceedButtonProps; |
||||
|
||||
return ( |
||||
<Dialog {...{ open }} PaperComponent={Panel}> |
||||
<PanelHeader> |
||||
<HeaderText text={titleText} /> |
||||
</PanelHeader> |
||||
<BodyText sx={{ marginBottom: '1em' }} text={contentText} /> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
justifyContent: 'flex-end', |
||||
width: '100%', |
||||
|
||||
'& > :not(:first-child)': { |
||||
marginLeft: '.5em', |
||||
}, |
||||
}} |
||||
> |
||||
<ContainedButton onClick={onCancel}>{actionCancelText}</ContainedButton> |
||||
<ContainedButton sx={proceedButtonSx} onClick={onProceed}> |
||||
{actionProceedText} |
||||
</ContainedButton> |
||||
</Box> |
||||
</Dialog> |
||||
); |
||||
}; |
||||
|
||||
ConfirmDialog.defaultProps = CONFIRM_DIALOG_DEFAULT_PROPS; |
||||
|
||||
export default ConfirmDialog; |
@ -0,0 +1,26 @@ |
||||
import { Button, ButtonProps, styled } from '@mui/material'; |
||||
|
||||
import { BLACK, GREY, TEXT } from '../lib/consts/DEFAULT_THEME'; |
||||
|
||||
const StyledButton = styled(Button)({ |
||||
backgroundColor: TEXT, |
||||
color: BLACK, |
||||
textTransform: 'none', |
||||
|
||||
'&:hover': { |
||||
backgroundColor: GREY, |
||||
}, |
||||
}); |
||||
|
||||
const ContainedButton = ({ |
||||
children, |
||||
onClick, |
||||
sx, |
||||
type, |
||||
}: ButtonProps): JSX.Element => ( |
||||
<StyledButton {...{ onClick, sx, type }} variant="contained"> |
||||
{children} |
||||
</StyledButton> |
||||
); |
||||
|
||||
export default ContainedButton; |
@ -1,12 +1,10 @@ |
||||
import { Panel } from './Panels'; |
||||
import { HeaderText } from './Text'; |
||||
|
||||
const Domain = (): JSX.Element => { |
||||
return ( |
||||
<Panel> |
||||
<HeaderText text="Domain Settings" /> |
||||
</Panel> |
||||
); |
||||
}; |
||||
const Domain = (): JSX.Element => ( |
||||
<Panel> |
||||
<HeaderText text="Domain Settings" /> |
||||
</Panel> |
||||
); |
||||
|
||||
export default Domain; |
||||
|
@ -0,0 +1,343 @@ |
||||
import { AxiosResponse } from 'axios'; |
||||
import { |
||||
FormEventHandler, |
||||
MouseEventHandler, |
||||
useEffect, |
||||
useState, |
||||
} from 'react'; |
||||
import { Box, Checkbox, checkboxClasses } from '@mui/material'; |
||||
|
||||
import API_BASE_URL from '../../lib/consts/API_BASE_URL'; |
||||
import { GREY, RED, TEXT } from '../../lib/consts/DEFAULT_THEME'; |
||||
|
||||
import ConfirmDialog from '../ConfirmDialog'; |
||||
import ContainedButton from '../ContainedButton'; |
||||
import FileInfo from './FileInfo'; |
||||
import Spinner from '../Spinner'; |
||||
|
||||
import fetchJSON from '../../lib/fetchers/fetchJSON'; |
||||
import mainAxiosInstance from '../../lib/singletons/mainAxiosInstance'; |
||||
|
||||
type ReducedFileLocation = Partial< |
||||
Pick<FileLocation, 'fileLocationUUID' | 'isFileLocationActive'> |
||||
>; |
||||
|
||||
type EditRequestContent = Partial< |
||||
Pick<FileDetailMetadata, 'fileName' | 'fileType' | 'fileUUID'> |
||||
> & { |
||||
fileLocations: ReducedFileLocation[]; |
||||
}; |
||||
|
||||
type FileEditProps = { |
||||
filesOverview: FileOverviewMetadata[]; |
||||
onEditFilesComplete?: () => void; |
||||
onPurgeFilesComplete?: () => void; |
||||
}; |
||||
|
||||
type FileToEdit = FileDetailMetadata & { |
||||
dataIncompleteError?: unknown; |
||||
isSelected?: boolean; |
||||
}; |
||||
|
||||
const FILE_EDIT_FORM_DEFAULT_PROPS = { |
||||
onEditFilesComplete: undefined, |
||||
onPurgeFilesComplete: undefined, |
||||
}; |
||||
|
||||
const FileEditForm = ( |
||||
{ |
||||
filesOverview, |
||||
onEditFilesComplete, |
||||
onPurgeFilesComplete, |
||||
}: FileEditProps = FILE_EDIT_FORM_DEFAULT_PROPS as FileEditProps, |
||||
): JSX.Element => { |
||||
const [editRequestContents, setEditRequestContents] = useState< |
||||
EditRequestContent[] |
||||
>([]); |
||||
const [filesToEdit, setFilesToEdit] = useState<FileToEdit[]>([]); |
||||
const [isLoadingFilesToEdit, setIsLoadingFilesToEdit] = |
||||
useState<boolean>(false); |
||||
const [isOpenPurgeConfirmDialog, setIsOpenConfirmPurgeDialog] = |
||||
useState<boolean>(false); |
||||
const [selectedFilesCount, setSelectedFilesCount] = useState<number>(0); |
||||
|
||||
const purgeButtonStyleOverride = { |
||||
backgroundColor: RED, |
||||
color: TEXT, |
||||
|
||||
'&:hover': { backgroundColor: RED }, |
||||
}; |
||||
|
||||
const generateFileInfoChangeHandler = |
||||
(fileIndex: number): FileInfoChangeHandler => |
||||
(inputValues, { fileLocationIndex } = {}) => { |
||||
if (fileLocationIndex !== undefined) { |
||||
editRequestContents[fileIndex].fileLocations[fileLocationIndex] = { |
||||
...editRequestContents[fileIndex].fileLocations[fileLocationIndex], |
||||
...inputValues, |
||||
}; |
||||
} else { |
||||
editRequestContents[fileIndex] = { |
||||
...editRequestContents[fileIndex], |
||||
...inputValues, |
||||
}; |
||||
} |
||||
}; |
||||
|
||||
const editFiles: FormEventHandler<HTMLFormElement> = (event) => { |
||||
event.preventDefault(); |
||||
|
||||
setIsLoadingFilesToEdit(true); |
||||
|
||||
const editPromises = editRequestContents.reduce<Promise<AxiosResponse>[]>( |
||||
( |
||||
reducedEditPromises, |
||||
{ fileLocations, fileName, fileType, fileUUID }, |
||||
) => { |
||||
const editRequestContent: Partial<EditRequestContent> = {}; |
||||
|
||||
if (fileName !== undefined) { |
||||
editRequestContent.fileName = fileName; |
||||
} |
||||
|
||||
if (fileType !== undefined) { |
||||
editRequestContent.fileType = fileType; |
||||
} |
||||
|
||||
const changedFileLocations = fileLocations.reduce< |
||||
ReducedFileLocation[] |
||||
>( |
||||
( |
||||
reducedFileLocations, |
||||
{ fileLocationUUID, isFileLocationActive }, |
||||
) => { |
||||
if (isFileLocationActive !== undefined) { |
||||
reducedFileLocations.push({ |
||||
fileLocationUUID, |
||||
isFileLocationActive, |
||||
}); |
||||
} |
||||
|
||||
return reducedFileLocations; |
||||
}, |
||||
[], |
||||
); |
||||
|
||||
if (changedFileLocations.length > 0) { |
||||
editRequestContent.fileLocations = changedFileLocations; |
||||
} |
||||
|
||||
const stringEditFileRequestContent = JSON.stringify(editRequestContent); |
||||
|
||||
if (stringEditFileRequestContent !== '{}') { |
||||
reducedEditPromises.push( |
||||
mainAxiosInstance.put( |
||||
`/files/${fileUUID}`, |
||||
stringEditFileRequestContent, |
||||
{ |
||||
headers: { 'Content-Type': 'application/json' }, |
||||
}, |
||||
), |
||||
); |
||||
} |
||||
|
||||
return reducedEditPromises; |
||||
}, |
||||
[], |
||||
); |
||||
|
||||
Promise.all(editPromises) |
||||
.then(() => { |
||||
setIsLoadingFilesToEdit(false); |
||||
}) |
||||
.then(onEditFilesComplete); |
||||
}; |
||||
|
||||
const purgeFiles: MouseEventHandler<HTMLButtonElement> = () => { |
||||
setIsOpenConfirmPurgeDialog(false); |
||||
setIsLoadingFilesToEdit(true); |
||||
|
||||
const purgePromises = filesToEdit |
||||
.filter(({ isSelected }) => isSelected) |
||||
.map(({ fileUUID }) => mainAxiosInstance.delete(`/files/${fileUUID}`)); |
||||
|
||||
Promise.all(purgePromises) |
||||
.then(() => { |
||||
setIsLoadingFilesToEdit(false); |
||||
}) |
||||
.then(onPurgeFilesComplete); |
||||
}; |
||||
|
||||
const cancelPurge: MouseEventHandler<HTMLButtonElement> = () => { |
||||
setIsOpenConfirmPurgeDialog(false); |
||||
}; |
||||
|
||||
const confirmPurge: MouseEventHandler<HTMLButtonElement> = () => { |
||||
// We need this local variable because setState functions are async; the
|
||||
// changes won't reflect until the next render cycle.
|
||||
// In this case, the user would have to click on the purge button twice to
|
||||
// trigger the confirmation dialog without using this local variable.
|
||||
const localSelectedFilesCount = filesToEdit.filter( |
||||
({ isSelected }) => isSelected, |
||||
).length; |
||||
|
||||
setSelectedFilesCount(localSelectedFilesCount); |
||||
|
||||
if (localSelectedFilesCount > 0) { |
||||
setIsOpenConfirmPurgeDialog(true); |
||||
} |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
setIsLoadingFilesToEdit(true); |
||||
|
||||
Promise.all( |
||||
filesOverview.map(async (fileOverview: FileOverviewMetadata) => { |
||||
const fileToEdit: FileToEdit = { |
||||
...fileOverview, |
||||
fileLocations: [], |
||||
}; |
||||
|
||||
try { |
||||
const data = await fetchJSON<string[][]>( |
||||
`${API_BASE_URL}/files/${fileOverview.fileUUID}`, |
||||
); |
||||
|
||||
fileToEdit.fileLocations = data.map( |
||||
([ |
||||
, |
||||
, |
||||
, |
||||
, |
||||
, |
||||
fileLocationUUID, |
||||
fileLocationActive, |
||||
anvilUUID, |
||||
anvilName, |
||||
anvilDescription, |
||||
]) => ({ |
||||
anvilDescription, |
||||
anvilName, |
||||
anvilUUID, |
||||
fileLocationUUID, |
||||
isFileLocationActive: parseInt(fileLocationActive, 10) === 1, |
||||
}), |
||||
); |
||||
} catch (fetchError) { |
||||
fileToEdit.dataIncompleteError = fetchError; |
||||
} |
||||
|
||||
return fileToEdit; |
||||
}), |
||||
).then((fetchedFilesDetail) => { |
||||
setFilesToEdit(fetchedFilesDetail); |
||||
|
||||
const initialEditRequestContents: EditRequestContent[] = []; |
||||
|
||||
for ( |
||||
let fileIndex = 0; |
||||
fileIndex < fetchedFilesDetail.length; |
||||
fileIndex += 1 |
||||
) { |
||||
const fetchedFileDetail = fetchedFilesDetail[fileIndex]; |
||||
initialEditRequestContents.push({ |
||||
fileUUID: fetchedFileDetail.fileUUID, |
||||
fileLocations: fetchedFileDetail.fileLocations.map( |
||||
({ fileLocationUUID }) => ({ |
||||
fileLocationUUID, |
||||
}), |
||||
), |
||||
}); |
||||
} |
||||
|
||||
setEditRequestContents(initialEditRequestContents); |
||||
|
||||
setIsLoadingFilesToEdit(false); |
||||
}); |
||||
}, [filesOverview]); |
||||
|
||||
return ( |
||||
<> |
||||
{isLoadingFilesToEdit ? ( |
||||
<Spinner /> |
||||
) : ( |
||||
<form onSubmit={editFiles}> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
'& > :not(:first-child)': { marginTop: '1em' }, |
||||
}} |
||||
> |
||||
{filesToEdit.map( |
||||
({ fileName, fileLocations, fileType, fileUUID }, fileIndex) => ( |
||||
<Box |
||||
key={`file-edit-${fileUUID}`} |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
'& > :last-child': { |
||||
flexGrow: 1, |
||||
}, |
||||
}} |
||||
> |
||||
<Box sx={{ marginTop: '.4em' }}> |
||||
<Checkbox |
||||
onChange={({ target: { checked } }) => { |
||||
filesToEdit[fileIndex].isSelected = checked; |
||||
}} |
||||
sx={{ |
||||
color: GREY, |
||||
|
||||
[`&.${checkboxClasses.checked}`]: { |
||||
color: TEXT, |
||||
}, |
||||
}} |
||||
/> |
||||
</Box> |
||||
<FileInfo |
||||
{...{ fileName, fileType, fileLocations }} |
||||
onChange={generateFileInfoChangeHandler(fileIndex)} |
||||
/> |
||||
</Box> |
||||
), |
||||
)} |
||||
{filesToEdit.length > 0 && ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
justifyContent: 'flex-end', |
||||
'& > :not(:last-child)': { |
||||
marginRight: '.5em', |
||||
}, |
||||
}} |
||||
> |
||||
<ContainedButton |
||||
onClick={confirmPurge} |
||||
sx={purgeButtonStyleOverride} |
||||
> |
||||
Purge |
||||
</ContainedButton> |
||||
<ContainedButton type="submit">Update</ContainedButton> |
||||
</Box> |
||||
)} |
||||
</Box> |
||||
<ConfirmDialog |
||||
actionProceedText="Purge" |
||||
contentText={`${selectedFilesCount} files will be removed from the system. You cannot undo this purge.`} |
||||
dialogProps={{ open: isOpenPurgeConfirmDialog }} |
||||
onCancel={cancelPurge} |
||||
onProceed={purgeFiles} |
||||
proceedButtonProps={{ sx: purgeButtonStyleOverride }} |
||||
titleText={`Are you sure you want to purge ${selectedFilesCount} selected files? `} |
||||
/> |
||||
</form> |
||||
)} |
||||
</> |
||||
); |
||||
}; |
||||
|
||||
FileEditForm.defaultProps = FILE_EDIT_FORM_DEFAULT_PROPS; |
||||
|
||||
export default FileEditForm; |
@ -0,0 +1,166 @@ |
||||
import { |
||||
Checkbox, |
||||
checkboxClasses, |
||||
FormControl, |
||||
FormControlLabel, |
||||
FormGroup, |
||||
MenuItem, |
||||
menuItemClasses, |
||||
Select, |
||||
selectClasses, |
||||
styled, |
||||
} from '@mui/material'; |
||||
import { |
||||
Sync as SyncIcon, |
||||
SyncDisabled as SyncDisabledIcon, |
||||
} from '@mui/icons-material'; |
||||
import { v4 as uuidv4 } from 'uuid'; |
||||
|
||||
import { BLUE, GREY, RED, TEXT } from '../../lib/consts/DEFAULT_THEME'; |
||||
import { UPLOAD_FILE_TYPES_ARRAY } from '../../lib/consts/UPLOAD_FILE_TYPES'; |
||||
|
||||
import OutlinedInput from '../OutlinedInput'; |
||||
import OutlinedInputLabel from '../OutlinedInputLabel'; |
||||
|
||||
type FileInfoProps = Pick<FileDetailMetadata, 'fileName' | 'fileLocations'> & |
||||
Partial<Pick<FileDetailMetadata, 'fileType'>> & { |
||||
isReadonly?: boolean; |
||||
onChange?: FileInfoChangeHandler; |
||||
}; |
||||
|
||||
const FILE_INFO_DEFAULT_PROPS: Partial<FileInfoProps> = { |
||||
isReadonly: undefined, |
||||
onChange: undefined, |
||||
}; |
||||
|
||||
const StyledSelect = styled(Select)({ |
||||
[`& .${selectClasses.icon}`]: { |
||||
color: GREY, |
||||
}, |
||||
}); |
||||
|
||||
const StyledMenuItem = styled(MenuItem)({ |
||||
backgroundColor: TEXT, |
||||
paddingRight: '3em', |
||||
|
||||
[`&.${menuItemClasses.selected}`]: { |
||||
backgroundColor: GREY, |
||||
fontWeight: 400, |
||||
|
||||
'&:hover': { |
||||
backgroundColor: GREY, |
||||
}, |
||||
}, |
||||
|
||||
'&:hover': { |
||||
backgroundColor: GREY, |
||||
}, |
||||
}); |
||||
|
||||
const FileLocationActiveCheckbox = styled(Checkbox)({ |
||||
color: RED, |
||||
|
||||
[`&.${checkboxClasses.checked}`]: { |
||||
color: BLUE, |
||||
}, |
||||
}); |
||||
|
||||
const FileInfo = ( |
||||
{ |
||||
fileName, |
||||
fileType, |
||||
fileLocations, |
||||
isReadonly, |
||||
onChange, |
||||
}: FileInfoProps = FILE_INFO_DEFAULT_PROPS as FileInfoProps, |
||||
): JSX.Element => { |
||||
const idExtension = uuidv4(); |
||||
|
||||
const fileNameElementId = `file-name-${idExtension}`; |
||||
const fileNameElementLabel = 'File name'; |
||||
|
||||
const fileTypeElementId = `file-type-${idExtension}`; |
||||
const fileTypeElementLabel = 'File type'; |
||||
|
||||
return ( |
||||
<FormGroup sx={{ '> :not(:first-child)': { marginTop: '1em' } }}> |
||||
<FormControl> |
||||
<OutlinedInputLabel htmlFor={fileNameElementId}> |
||||
{fileNameElementLabel} |
||||
</OutlinedInputLabel> |
||||
<OutlinedInput |
||||
defaultValue={fileName} |
||||
disabled={isReadonly} |
||||
id={fileNameElementId} |
||||
label={fileNameElementLabel} |
||||
onChange={({ target: { value } }) => { |
||||
onChange?.call(null, { |
||||
fileName: value === fileName ? undefined : value, |
||||
}); |
||||
}} |
||||
/> |
||||
</FormControl> |
||||
{fileType && ( |
||||
<FormControl> |
||||
<OutlinedInputLabel htmlFor={fileTypeElementId}> |
||||
{fileTypeElementLabel} |
||||
</OutlinedInputLabel> |
||||
<StyledSelect |
||||
defaultValue={fileType} |
||||
disabled={isReadonly} |
||||
id={fileTypeElementId} |
||||
input={<OutlinedInput label={fileTypeElementLabel} />} |
||||
onChange={({ target: { value } }) => { |
||||
onChange?.call(null, { |
||||
fileType: value === fileType ? undefined : (value as FileType), |
||||
}); |
||||
}} |
||||
> |
||||
{UPLOAD_FILE_TYPES_ARRAY.map( |
||||
([fileTypeKey, [, fileTypeDisplayString]]) => ( |
||||
<StyledMenuItem key={fileTypeKey} value={fileTypeKey}> |
||||
{fileTypeDisplayString} |
||||
</StyledMenuItem> |
||||
), |
||||
)} |
||||
</StyledSelect> |
||||
</FormControl> |
||||
)} |
||||
{fileLocations.map( |
||||
( |
||||
{ anvilName, anvilDescription, anvilUUID, isFileLocationActive }, |
||||
fileLocationIndex, |
||||
) => ( |
||||
<FormControlLabel |
||||
control={ |
||||
<FileLocationActiveCheckbox |
||||
checkedIcon={<SyncIcon />} |
||||
defaultChecked={isFileLocationActive} |
||||
disabled={isReadonly} |
||||
icon={<SyncDisabledIcon />} |
||||
onChange={({ target: { checked } }) => { |
||||
onChange?.call( |
||||
null, |
||||
{ |
||||
isFileLocationActive: |
||||
checked === isFileLocationActive ? undefined : checked, |
||||
}, |
||||
{ fileLocationIndex }, |
||||
); |
||||
}} |
||||
/> |
||||
} |
||||
key={anvilUUID} |
||||
label={`${anvilName}: ${anvilDescription}`} |
||||
sx={{ color: TEXT }} |
||||
value={`${anvilUUID}-sync`} |
||||
/> |
||||
), |
||||
)} |
||||
</FormGroup> |
||||
); |
||||
}; |
||||
|
||||
FileInfo.defaultProps = FILE_INFO_DEFAULT_PROPS; |
||||
|
||||
export default FileInfo; |
@ -0,0 +1,80 @@ |
||||
import { Box, Divider, List, ListItem } from '@mui/material'; |
||||
import * as prettyBytes from 'pretty-bytes'; |
||||
|
||||
import { DIVIDER } from '../../lib/consts/DEFAULT_THEME'; |
||||
import { UPLOAD_FILE_TYPES } from '../../lib/consts/UPLOAD_FILE_TYPES'; |
||||
|
||||
import { BodyText } from '../Text'; |
||||
|
||||
type FileListProps = { |
||||
filesOverview: FileOverviewMetadata[]; |
||||
}; |
||||
|
||||
const FileList = ({ filesOverview }: FileListProps): JSX.Element => ( |
||||
<List> |
||||
{filesOverview.map( |
||||
({ fileChecksum, fileName, fileSizeInBytes, fileType, fileUUID }) => { |
||||
const fileSize: string = prettyBytes.default(fileSizeInBytes, { |
||||
binary: true, |
||||
}); |
||||
|
||||
return ( |
||||
<ListItem key={fileUUID} sx={{ padding: '.6em 0' }}> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: { xs: 'column', md: 'row' }, |
||||
width: '100%', |
||||
}} |
||||
> |
||||
<Box sx={{ flexGrow: 1 }}> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
}} |
||||
> |
||||
<BodyText |
||||
sx={{ |
||||
fontFamily: 'Source Code Pro', |
||||
fontWeight: 400, |
||||
}} |
||||
text={fileName} |
||||
/> |
||||
<Divider |
||||
flexItem |
||||
orientation="vertical" |
||||
sx={{ |
||||
backgroundColor: DIVIDER, |
||||
marginLeft: '.5em', |
||||
marginRight: '.5em', |
||||
}} |
||||
/> |
||||
<BodyText text={UPLOAD_FILE_TYPES.get(fileType)?.[1] ?? ''} /> |
||||
</Box> |
||||
<BodyText text={fileSize} /> |
||||
</Box> |
||||
<Box |
||||
sx={{ |
||||
alignItems: 'center', |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
}} |
||||
> |
||||
<BodyText |
||||
sx={{ |
||||
fontFamily: 'Source Code Pro', |
||||
fontWeight: 400, |
||||
}} |
||||
text={fileChecksum} |
||||
/> |
||||
</Box> |
||||
</Box> |
||||
</ListItem> |
||||
); |
||||
}, |
||||
)} |
||||
</List> |
||||
); |
||||
|
||||
export default FileList; |
@ -0,0 +1,243 @@ |
||||
import EventEmitter from 'events'; |
||||
import { |
||||
ChangeEventHandler, |
||||
FormEventHandler, |
||||
useEffect, |
||||
useRef, |
||||
useState, |
||||
} from 'react'; |
||||
import { Box, Input, InputLabel } from '@mui/material'; |
||||
|
||||
import { UPLOAD_FILE_TYPES } from '../../lib/consts/UPLOAD_FILE_TYPES'; |
||||
|
||||
import { ProgressBar } from '../Bars'; |
||||
import ContainedButton from '../ContainedButton'; |
||||
import FileInfo from './FileInfo'; |
||||
import MessageBox from '../MessageBox'; |
||||
import { BodyText } from '../Text'; |
||||
|
||||
import mainAxiosInstance from '../../lib/singletons/mainAxiosInstance'; |
||||
|
||||
type FileUploadFormProps = { |
||||
onFileUploadComplete?: () => void; |
||||
eventEmitter?: EventEmitter; |
||||
}; |
||||
|
||||
type SelectedFile = Pick< |
||||
FileDetailMetadata, |
||||
'fileName' | 'fileLocations' | 'fileType' |
||||
> & { |
||||
file: File; |
||||
}; |
||||
|
||||
type InUploadFile = Pick<FileDetailMetadata, 'fileName'> & { |
||||
progressValue: number; |
||||
}; |
||||
|
||||
const FILE_UPLOAD_FORM_DEFAULT_PROPS: Partial<FileUploadFormProps> = { |
||||
onFileUploadComplete: undefined, |
||||
eventEmitter: undefined, |
||||
}; |
||||
|
||||
const FileUploadForm = ( |
||||
{ |
||||
onFileUploadComplete, |
||||
eventEmitter, |
||||
}: FileUploadFormProps = FILE_UPLOAD_FORM_DEFAULT_PROPS as FileUploadFormProps, |
||||
): JSX.Element => { |
||||
const selectFileRef = useRef<HTMLInputElement>(); |
||||
|
||||
const [selectedFiles, setSelectedFiles] = useState<SelectedFile[]>([]); |
||||
const [inUploadFiles, setInUploadFiles] = useState<InUploadFile[]>([]); |
||||
|
||||
const convertMIMETypeToFileTypeKey = (fileMIMEType: string): FileType => { |
||||
const fileTypesIterator = UPLOAD_FILE_TYPES.entries(); |
||||
|
||||
let fileType: FileType | undefined; |
||||
|
||||
do { |
||||
const fileTypesResult = fileTypesIterator.next(); |
||||
|
||||
if (fileTypesResult.value) { |
||||
const [fileTypeKey, [mimeTypeToUse]] = fileTypesResult.value; |
||||
|
||||
if (fileMIMEType === mimeTypeToUse) { |
||||
fileType = fileTypeKey; |
||||
} |
||||
} else { |
||||
fileType = 'other'; |
||||
} |
||||
} while (!fileType); |
||||
|
||||
return fileType; |
||||
}; |
||||
|
||||
const autocompleteAfterSelectFile: ChangeEventHandler<HTMLInputElement> = ({ |
||||
target: { files }, |
||||
}) => { |
||||
if (files) { |
||||
setSelectedFiles( |
||||
Array.from(files).map( |
||||
(file): SelectedFile => ({ |
||||
file, |
||||
fileName: file.name, |
||||
fileLocations: [], |
||||
fileType: convertMIMETypeToFileTypeKey(file.type), |
||||
}), |
||||
), |
||||
); |
||||
} |
||||
}; |
||||
|
||||
const generateFileInfoOnChangeHandler = |
||||
(fileIndex: number): FileInfoChangeHandler => |
||||
(inputValues) => { |
||||
selectedFiles[fileIndex] = { |
||||
...selectedFiles[fileIndex], |
||||
...inputValues, |
||||
}; |
||||
}; |
||||
|
||||
const uploadFiles: FormEventHandler<HTMLFormElement> = (event) => { |
||||
event.preventDefault(); |
||||
|
||||
while (selectedFiles.length > 0) { |
||||
const selectedFile = selectedFiles.shift(); |
||||
|
||||
if (selectedFile) { |
||||
const { file, fileName } = selectedFile; |
||||
|
||||
const fileFormData = new FormData(); |
||||
|
||||
fileFormData.append('file', new File([file], fileName, { ...file })); |
||||
// Re-add when the back-end tools can support changing file type on file upload.
|
||||
// Note: get file type from destructuring selectedFile.
|
||||
// fileFormData.append('file-type', fileType);
|
||||
|
||||
const inUploadFile: InUploadFile = { fileName, progressValue: 0 }; |
||||
inUploadFiles.push(inUploadFile); |
||||
|
||||
mainAxiosInstance |
||||
.post('/files', fileFormData, { |
||||
headers: { |
||||
'Content-Type': 'multipart/form-data', |
||||
}, |
||||
onUploadProgress: ({ loaded, total }) => { |
||||
inUploadFile.progressValue = Math.round((loaded / total) * 100); |
||||
setInUploadFiles([...inUploadFiles]); |
||||
}, |
||||
}) |
||||
.then(() => { |
||||
onFileUploadComplete?.call(null); |
||||
|
||||
inUploadFiles.splice(inUploadFiles.indexOf(inUploadFile), 1); |
||||
setInUploadFiles([...inUploadFiles]); |
||||
}); |
||||
} |
||||
} |
||||
|
||||
// Clears "staging area" (selected files) and populates "in-progress area" (in-upload files).
|
||||
setSelectedFiles([]); |
||||
setInUploadFiles([...inUploadFiles]); |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
eventEmitter?.addListener('openFilePicker', () => { |
||||
selectFileRef.current?.click(); |
||||
}); |
||||
|
||||
eventEmitter?.addListener('clearSelectedFiles', () => { |
||||
setSelectedFiles([]); |
||||
}); |
||||
}, [eventEmitter]); |
||||
|
||||
return ( |
||||
<form onSubmit={uploadFiles}> |
||||
<InputLabel htmlFor="select-file"> |
||||
<Input |
||||
id="select-file" |
||||
inputProps={{ multiple: true }} |
||||
onChange={autocompleteAfterSelectFile} |
||||
ref={selectFileRef} |
||||
sx={{ display: 'none' }} |
||||
type="file" |
||||
/> |
||||
</InputLabel> |
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}> |
||||
{inUploadFiles.map(({ fileName, progressValue }) => ( |
||||
<Box |
||||
key={`in-upload-${fileName}`} |
||||
sx={{ |
||||
alignItems: { md: 'center' }, |
||||
display: 'flex', |
||||
flexDirection: { xs: 'column', md: 'row' }, |
||||
'& > :first-child': { |
||||
minWidth: 100, |
||||
overflow: 'hidden', |
||||
overflowWrap: 'normal', |
||||
textOverflow: 'ellipsis', |
||||
whiteSpace: 'nowrap', |
||||
width: { xs: '100%', md: 200 }, |
||||
wordBreak: 'keep-all', |
||||
}, |
||||
'& > :last-child': { flexGrow: 1 }, |
||||
}} |
||||
> |
||||
<BodyText text={fileName} /> |
||||
<ProgressBar progressPercentage={progressValue} /> |
||||
</Box> |
||||
))} |
||||
</Box> |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
'& > :not(:first-child)': { marginTop: '1em' }, |
||||
}} |
||||
> |
||||
{selectedFiles.length > 0 && ( |
||||
<MessageBox |
||||
text="Uploaded files will be listed automatically, but it may take a while for larger files to appear." |
||||
type="info" |
||||
/> |
||||
)} |
||||
{selectedFiles.map( |
||||
( |
||||
{ |
||||
file: { name: originalFileName }, |
||||
fileName, |
||||
// Re-add when the back-end tools can support changing file type on file upload.
|
||||
// Note: file type must be supplied to FileInfo.
|
||||
// fileType,
|
||||
fileLocations, |
||||
}, |
||||
fileIndex, |
||||
) => ( |
||||
<FileInfo |
||||
{...{ fileName, fileLocations }} |
||||
// Use a non-changing key to prevent recreating the component.
|
||||
// fileName holds the string from the file-name input, thus it changes when users makes a change.
|
||||
key={`selected-${originalFileName}`} |
||||
onChange={generateFileInfoOnChangeHandler(fileIndex)} |
||||
/> |
||||
), |
||||
)} |
||||
{selectedFiles.length > 0 && ( |
||||
<Box |
||||
sx={{ |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
justifyContent: 'flex-end', |
||||
}} |
||||
> |
||||
<ContainedButton type="submit">Upload</ContainedButton> |
||||
</Box> |
||||
)} |
||||
</Box> |
||||
</form> |
||||
); |
||||
}; |
||||
|
||||
FileUploadForm.defaultProps = FILE_UPLOAD_FORM_DEFAULT_PROPS; |
||||
|
||||
export default FileUploadForm; |
@ -0,0 +1,148 @@ |
||||
import { useEffect, useState } from 'react'; |
||||
import { Box } from '@mui/material'; |
||||
import { |
||||
Add as AddIcon, |
||||
Check as CheckIcon, |
||||
Edit as EditIcon, |
||||
} from '@mui/icons-material'; |
||||
import EventEmitter from 'events'; |
||||
|
||||
import API_BASE_URL from '../../lib/consts/API_BASE_URL'; |
||||
import { BLUE } from '../../lib/consts/DEFAULT_THEME'; |
||||
|
||||
import FileEditForm from './FileEditForm'; |
||||
import FileList from './FileList'; |
||||
import FileUploadForm from './FileUploadForm'; |
||||
import IconButton from '../IconButton'; |
||||
import { Panel } from '../Panels'; |
||||
import MessageBox from '../MessageBox'; |
||||
import Spinner from '../Spinner'; |
||||
import { HeaderText } from '../Text'; |
||||
|
||||
import fetchJSON from '../../lib/fetchers/fetchJSON'; |
||||
import periodicFetch from '../../lib/fetchers/periodicFetch'; |
||||
|
||||
const FILES_ENDPOINT_URL = `${API_BASE_URL}/files`; |
||||
|
||||
const Files = (): JSX.Element => { |
||||
const [rawFilesOverview, setRawFilesOverview] = useState<string[][]>([]); |
||||
const [fetchRawFilesError, setFetchRawFilesError] = useState<string>(); |
||||
const [isLoadingRawFilesOverview, setIsLoadingRawFilesOverview] = |
||||
useState<boolean>(false); |
||||
const [isEditMode, setIsEditMode] = useState<boolean>(false); |
||||
|
||||
const fileUploadFormEventEmitter: EventEmitter = new EventEmitter(); |
||||
|
||||
const onAddFileButtonClick = () => { |
||||
fileUploadFormEventEmitter.emit('openFilePicker'); |
||||
}; |
||||
|
||||
const onEditFileButtonClick = () => { |
||||
fileUploadFormEventEmitter.emit('clearSelectedFiles'); |
||||
|
||||
setIsEditMode(!isEditMode); |
||||
}; |
||||
|
||||
const fetchRawFilesOverview = async () => { |
||||
setIsLoadingRawFilesOverview(true); |
||||
|
||||
try { |
||||
const data = await fetchJSON<string[][]>(FILES_ENDPOINT_URL); |
||||
setRawFilesOverview(data); |
||||
} catch (fetchError) { |
||||
setFetchRawFilesError('Failed to get files due to a network issue.'); |
||||
} |
||||
|
||||
setIsLoadingRawFilesOverview(false); |
||||
}; |
||||
|
||||
const buildFileList = (): JSX.Element => { |
||||
let elements: JSX.Element; |
||||
if (isLoadingRawFilesOverview) { |
||||
elements = <Spinner />; |
||||
} else { |
||||
const filesOverview: FileOverviewMetadata[] = rawFilesOverview.map( |
||||
([fileUUID, fileName, fileSizeInBytes, fileType, fileChecksum]) => ({ |
||||
fileChecksum, |
||||
fileName, |
||||
fileSizeInBytes: parseInt(fileSizeInBytes, 10), |
||||
fileType: fileType as FileType, |
||||
fileUUID, |
||||
}), |
||||
); |
||||
|
||||
elements = isEditMode ? ( |
||||
<FileEditForm |
||||
{...{ filesOverview }} |
||||
onEditFilesComplete={fetchRawFilesOverview} |
||||
onPurgeFilesComplete={fetchRawFilesOverview} |
||||
/> |
||||
) : ( |
||||
<FileList {...{ filesOverview }} /> |
||||
); |
||||
} |
||||
|
||||
return elements; |
||||
}; |
||||
|
||||
/** |
||||
* Check for new files periodically and update the file list. |
||||
* |
||||
* We need this because adding new files is done async; adding the file may |
||||
* not finish before the request returns. |
||||
* |
||||
* We don't care about edit because database updates are done before the |
||||
* edit request returns. |
||||
*/ |
||||
periodicFetch<string[][]>(FILES_ENDPOINT_URL, { |
||||
onSuccess: (periodicFilesOverview) => { |
||||
if (periodicFilesOverview.length !== rawFilesOverview.length) { |
||||
setRawFilesOverview(periodicFilesOverview); |
||||
} |
||||
}, |
||||
}); |
||||
|
||||
useEffect(() => { |
||||
if (!isEditMode) { |
||||
fetchRawFilesOverview(); |
||||
} |
||||
}, [isEditMode]); |
||||
|
||||
return ( |
||||
<Panel> |
||||
<Box |
||||
sx={{ |
||||
alignItems: 'center', |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
marginBottom: '1em', |
||||
width: '100%', |
||||
'& > :first-child': { flexGrow: 1 }, |
||||
'& > :not(:first-child, :last-child)': { |
||||
marginRight: '.3em', |
||||
}, |
||||
}} |
||||
> |
||||
<HeaderText text="Files" /> |
||||
{!isEditMode && ( |
||||
<IconButton onClick={onAddFileButtonClick}> |
||||
<AddIcon /> |
||||
</IconButton> |
||||
)} |
||||
<IconButton onClick={onEditFileButtonClick}> |
||||
{isEditMode ? <CheckIcon sx={{ color: BLUE }} /> : <EditIcon />} |
||||
</IconButton> |
||||
</Box> |
||||
{fetchRawFilesError && ( |
||||
<MessageBox text={fetchRawFilesError} type="error" /> |
||||
)} |
||||
<FileUploadForm |
||||
{...{ eventEmitter: fileUploadFormEventEmitter }} |
||||
onFileUploadComplete={fetchRawFilesOverview} |
||||
/> |
||||
{buildFileList()} |
||||
</Panel> |
||||
); |
||||
}; |
||||
|
||||
export default Files; |
@ -0,0 +1,3 @@ |
||||
import Files from './Files'; |
||||
|
||||
export default Files; |
@ -0,0 +1,19 @@ |
||||
import { IconButton as MUIIconButton, styled } from '@mui/material'; |
||||
|
||||
import { |
||||
BLACK, |
||||
BORDER_RADIUS, |
||||
GREY, |
||||
TEXT, |
||||
} from '../../lib/consts/DEFAULT_THEME'; |
||||
|
||||
const IconButton = styled(MUIIconButton)({ |
||||
borderRadius: BORDER_RADIUS, |
||||
backgroundColor: GREY, |
||||
'&:hover': { |
||||
backgroundColor: TEXT, |
||||
}, |
||||
color: BLACK, |
||||
}); |
||||
|
||||
export default IconButton; |
@ -0,0 +1,3 @@ |
||||
import IconButton from './IconButton'; |
||||
|
||||
export default IconButton; |
@ -0,0 +1,101 @@ |
||||
import { Box, styled } from '@mui/material'; |
||||
import { |
||||
Error as ErrorIcon, |
||||
Info as InfoIcon, |
||||
Warning as WarningIcon, |
||||
} from '@mui/icons-material'; |
||||
|
||||
import { |
||||
BLACK, |
||||
BORDER_RADIUS, |
||||
GREY, |
||||
PURPLE, |
||||
RED, |
||||
TEXT, |
||||
} from '../lib/consts/DEFAULT_THEME'; |
||||
|
||||
import { BodyText } from './Text'; |
||||
|
||||
type MessageBoxType = 'error' | 'info' | 'warning'; |
||||
|
||||
type MessageBoxProps = { |
||||
text: string; |
||||
type: MessageBoxType; |
||||
}; |
||||
|
||||
const MESSAGE_BOX_CLASS_PREFIX = 'MessageBox'; |
||||
|
||||
const MESSAGE_BOX_CLASSES: Record<MessageBoxType, string> = { |
||||
error: `${MESSAGE_BOX_CLASS_PREFIX}-error`, |
||||
info: `${MESSAGE_BOX_CLASS_PREFIX}-info`, |
||||
warning: `${MESSAGE_BOX_CLASS_PREFIX}-warning`, |
||||
}; |
||||
|
||||
const StyledBox = styled(Box)({ |
||||
alignItems: 'center', |
||||
borderRadius: BORDER_RADIUS, |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
padding: '.3em .6em', |
||||
|
||||
'& > *': { |
||||
color: TEXT, |
||||
}, |
||||
|
||||
'& > :first-child': { |
||||
marginRight: '.3em', |
||||
}, |
||||
|
||||
[`&.${MESSAGE_BOX_CLASSES.error}`]: { |
||||
backgroundColor: RED, |
||||
}, |
||||
|
||||
[`&.${MESSAGE_BOX_CLASSES.info}`]: { |
||||
backgroundColor: GREY, |
||||
|
||||
'& > :first-child': { |
||||
color: `${BLACK}`, |
||||
}, |
||||
}, |
||||
|
||||
[`&.${MESSAGE_BOX_CLASSES.warning}`]: { |
||||
backgroundColor: PURPLE, |
||||
}, |
||||
}); |
||||
|
||||
const MessageBox = ({ type, text }: MessageBoxProps): JSX.Element => { |
||||
const buildMessageBoxClasses = (messageBoxType: MessageBoxType) => |
||||
MESSAGE_BOX_CLASSES[messageBoxType]; |
||||
|
||||
const buildMessageIcon = (messageBoxType: MessageBoxType) => { |
||||
let messageIcon; |
||||
|
||||
switch (messageBoxType) { |
||||
case 'error': |
||||
messageIcon = <ErrorIcon />; |
||||
break; |
||||
case 'warning': |
||||
messageIcon = <WarningIcon />; |
||||
break; |
||||
default: |
||||
messageIcon = <InfoIcon />; |
||||
} |
||||
|
||||
return messageIcon; |
||||
}; |
||||
|
||||
const buildMessage = (message: string, messageBoxType: MessageBoxType) => ( |
||||
<BodyText inverted={messageBoxType === 'info'} text={message} /> |
||||
); |
||||
|
||||
return ( |
||||
<StyledBox className={buildMessageBoxClasses(type)}> |
||||
{buildMessageIcon(type)} |
||||
{buildMessage(text, type)} |
||||
</StyledBox> |
||||
); |
||||
}; |
||||
|
||||
export type { MessageBoxProps, MessageBoxType }; |
||||
|
||||
export default MessageBox; |
@ -0,0 +1,35 @@ |
||||
import { |
||||
OutlinedInput as MUIOutlinedInput, |
||||
outlinedInputClasses as muiOutlinedInputClasses, |
||||
styled, |
||||
} from '@mui/material'; |
||||
|
||||
import { GREY, TEXT, UNSELECTED } from '../../lib/consts/DEFAULT_THEME'; |
||||
|
||||
const OutlinedInput = styled(MUIOutlinedInput)({ |
||||
color: GREY, |
||||
|
||||
[`& .${muiOutlinedInputClasses.notchedOutline}`]: { |
||||
borderColor: UNSELECTED, |
||||
}, |
||||
|
||||
'&:hover': { |
||||
[`& .${muiOutlinedInputClasses.notchedOutline}`]: { |
||||
borderColor: GREY, |
||||
}, |
||||
}, |
||||
|
||||
[`&.${muiOutlinedInputClasses.focused}`]: { |
||||
color: TEXT, |
||||
|
||||
[`& .${muiOutlinedInputClasses.notchedOutline}`]: { |
||||
borderColor: GREY, |
||||
|
||||
'& legend': { |
||||
paddingRight: '1.2em', |
||||
}, |
||||
}, |
||||
}, |
||||
}); |
||||
|
||||
export default OutlinedInput; |
@ -0,0 +1,3 @@ |
||||
import OutlinedInput from './OutlinedInput'; |
||||
|
||||
export default OutlinedInput; |
@ -0,0 +1,31 @@ |
||||
import { |
||||
InputLabel as MUIInputLabel, |
||||
inputLabelClasses as muiInputLabelClasses, |
||||
InputLabelProps as MUIInputLabelProps, |
||||
} from '@mui/material'; |
||||
|
||||
import { BLACK, BORDER_RADIUS, GREY } from '../../lib/consts/DEFAULT_THEME'; |
||||
|
||||
const OutlinedInputLabel = ({ |
||||
children, |
||||
htmlFor, |
||||
}: MUIInputLabelProps): JSX.Element => ( |
||||
<MUIInputLabel |
||||
{...{ htmlFor }} |
||||
sx={{ |
||||
color: GREY, |
||||
|
||||
[`&.${muiInputLabelClasses.focused}`]: { |
||||
backgroundColor: GREY, |
||||
borderRadius: BORDER_RADIUS, |
||||
color: BLACK, |
||||
padding: '.1em .6em', |
||||
}, |
||||
}} |
||||
variant="outlined" |
||||
> |
||||
{children} |
||||
</MUIInputLabel> |
||||
); |
||||
|
||||
export default OutlinedInputLabel; |
@ -0,0 +1,3 @@ |
||||
import OutlinedInputLabel from './OutlinedInputLabel'; |
||||
|
||||
export default OutlinedInputLabel; |
@ -1,29 +1,25 @@ |
||||
import { ReactNode } from 'react'; |
||||
import { Box } from '@material-ui/core'; |
||||
import { makeStyles } from '@material-ui/core/styles'; |
||||
import { Box } from '@mui/material'; |
||||
import { styled } from '@mui/material/styles'; |
||||
import { BORDER_RADIUS, DIVIDER } from '../../lib/consts/DEFAULT_THEME'; |
||||
|
||||
const StyledBox = styled(Box)({ |
||||
borderWidth: '1px', |
||||
borderRadius: BORDER_RADIUS, |
||||
borderStyle: 'solid', |
||||
borderColor: DIVIDER, |
||||
marginTop: '1.4em', |
||||
marginBottom: '1.4em', |
||||
paddingBottom: 0, |
||||
position: 'relative', |
||||
}); |
||||
|
||||
type Props = { |
||||
children: ReactNode; |
||||
}; |
||||
|
||||
const useStyles = makeStyles(() => ({ |
||||
innerBody: { |
||||
borderWidth: '1px', |
||||
borderRadius: BORDER_RADIUS, |
||||
borderStyle: 'solid', |
||||
borderColor: DIVIDER, |
||||
marginTop: '1.4em', |
||||
marginBottom: '1.4em', |
||||
paddingBottom: 0, |
||||
position: 'relative', |
||||
}, |
||||
})); |
||||
|
||||
const InnerPanel = ({ children }: Props): JSX.Element => { |
||||
const classes = useStyles(); |
||||
|
||||
return <Box className={classes.innerBody}>{children}</Box>; |
||||
}; |
||||
const InnerPanel = ({ children }: Props): JSX.Element => ( |
||||
<StyledBox>{children}</StyledBox> |
||||
); |
||||
|
||||
export default InnerPanel; |
||||
|
@ -0,0 +1,42 @@ |
||||
import { ReactNode } from 'react'; |
||||
import { Box, styled } from '@mui/material'; |
||||
|
||||
import { BORDER_RADIUS, DIVIDER } from '../../lib/consts/DEFAULT_THEME'; |
||||
|
||||
const PREFIX = 'InnerPanelHeader'; |
||||
|
||||
const classes = { |
||||
header: `${PREFIX}-header`, |
||||
}; |
||||
|
||||
const StyledBox = styled(Box)(() => ({ |
||||
position: 'relative', |
||||
padding: '0 .7em', |
||||
whiteSpace: 'pre-wrap', |
||||
|
||||
[`& .${classes.header}`]: { |
||||
top: '-.3em', |
||||
left: '-.3em', |
||||
padding: '1.4em 0', |
||||
position: 'absolute', |
||||
content: '""', |
||||
borderColor: DIVIDER, |
||||
borderWidth: '1px', |
||||
borderRadius: BORDER_RADIUS, |
||||
borderStyle: 'solid', |
||||
width: '100%', |
||||
}, |
||||
})); |
||||
|
||||
type Props = { |
||||
children: ReactNode; |
||||
}; |
||||
|
||||
const InnerPanelHeader = ({ children }: Props): JSX.Element => ( |
||||
<StyledBox> |
||||
<div className={classes.header} /> |
||||
{children} |
||||
</StyledBox> |
||||
); |
||||
|
||||
export default InnerPanelHeader; |
@ -1,40 +1,15 @@ |
||||
import { ReactNode } from 'react'; |
||||
import { Box } from '@material-ui/core'; |
||||
import { makeStyles } from '@material-ui/core/styles'; |
||||
import { BORDER_RADIUS, DIVIDER } from '../../lib/consts/DEFAULT_THEME'; |
||||
|
||||
type Props = { |
||||
children: ReactNode; |
||||
}; |
||||
|
||||
const useStyles = makeStyles(() => ({ |
||||
innerHeader: { |
||||
position: 'relative', |
||||
padding: '0 .7em', |
||||
}, |
||||
header: { |
||||
top: '-.3em', |
||||
left: '-.3em', |
||||
padding: '1.4em 0', |
||||
position: 'absolute', |
||||
content: '""', |
||||
borderColor: DIVIDER, |
||||
borderWidth: '1px', |
||||
borderRadius: BORDER_RADIUS, |
||||
borderStyle: 'solid', |
||||
width: '100%', |
||||
import { Box, styled } from '@mui/material'; |
||||
|
||||
const PanelHeader = styled(Box)({ |
||||
alignItems: 'center', |
||||
display: 'flex', |
||||
flexDirection: 'row', |
||||
marginBottom: '1em', |
||||
width: '100%', |
||||
'& > :first-child': { flexGrow: 1 }, |
||||
'& > :not(:first-child, :last-child)': { |
||||
marginRight: '.3em', |
||||
}, |
||||
})); |
||||
|
||||
const PanelHeader = ({ children }: Props): JSX.Element => { |
||||
const classes = useStyles(); |
||||
|
||||
return ( |
||||
<Box className={classes.innerHeader} whiteSpace="pre-wrap"> |
||||
<div className={classes.header} /> |
||||
{children} |
||||
</Box> |
||||
); |
||||
}; |
||||
}); |
||||
|
||||
export default PanelHeader; |
||||
|
@ -1,5 +1,6 @@ |
||||
import PanelHeader from './PanelHeader'; |
||||
import InnerPanel from './InnerPanel'; |
||||
import InnerPanelHeader from './InnerPanelHeader'; |
||||
import Panel from './Panel'; |
||||
import PanelHeader from './PanelHeader'; |
||||
|
||||
export { Panel, PanelHeader, InnerPanel }; |
||||
export { InnerPanel, InnerPanelHeader, Panel, PanelHeader }; |
||||
|
@ -1,29 +1,28 @@ |
||||
import { makeStyles } from '@material-ui/core/styles'; |
||||
import CircularProgress from '@material-ui/core/CircularProgress'; |
||||
import { styled } from '@mui/material/styles'; |
||||
import { CircularProgress } from '@mui/material'; |
||||
import { TEXT } from '../lib/consts/DEFAULT_THEME'; |
||||
|
||||
const useStyles = makeStyles(() => ({ |
||||
root: { |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
justifyContent: 'center', |
||||
marginTop: '3em', |
||||
}, |
||||
spinner: { |
||||
const PREFIX = 'Spinner'; |
||||
|
||||
const classes = { |
||||
spinner: `${PREFIX}-spinner`, |
||||
}; |
||||
|
||||
const StyledDiv = styled('div')(() => ({ |
||||
display: 'flex', |
||||
alignItems: 'center', |
||||
justifyContent: 'center', |
||||
marginTop: '3em', |
||||
|
||||
[`& .${classes.spinner}`]: { |
||||
color: TEXT, |
||||
variant: 'indeterminate', |
||||
size: '50em', |
||||
}, |
||||
})); |
||||
|
||||
const Spinner = (): JSX.Element => { |
||||
const classes = useStyles(); |
||||
|
||||
return ( |
||||
<div className={classes.root}> |
||||
<CircularProgress className={classes.spinner} /> |
||||
</div> |
||||
); |
||||
}; |
||||
const Spinner = (): JSX.Element => ( |
||||
<StyledDiv> |
||||
<CircularProgress variant="indeterminate" className={classes.spinner} /> |
||||
</StyledDiv> |
||||
); |
||||
|
||||
export default Spinner; |
||||
|
@ -1,15 +1,13 @@ |
||||
import { Typography } from '@material-ui/core'; |
||||
import { withStyles } from '@material-ui/core/styles'; |
||||
import Typography from '@mui/material/Typography'; |
||||
import { styled } from '@mui/material/styles'; |
||||
import { TEXT } from '../../lib/consts/DEFAULT_THEME'; |
||||
|
||||
const WhiteTypography = withStyles({ |
||||
root: { |
||||
color: TEXT, |
||||
}, |
||||
})(Typography); |
||||
const WhiteTypography = styled(Typography)({ |
||||
color: TEXT, |
||||
}); |
||||
|
||||
const HeaderText = ({ text }: { text: string }): JSX.Element => { |
||||
return <WhiteTypography variant="h4">{text}</WhiteTypography>; |
||||
}; |
||||
const HeaderText = ({ text }: { text: string }): JSX.Element => ( |
||||
<WhiteTypography variant="h4">{text}</WhiteTypography> |
||||
); |
||||
|
||||
export default HeaderText; |
||||
|
@ -1,7 +1,6 @@ |
||||
import IS_DEV_ENV from './IS_DEV_ENV'; |
||||
|
||||
const API_BASE_URL = IS_DEV_ENV |
||||
? process.env.DEVELOPMENT_API_BASE_URL |
||||
: process.env.PRODUCTION_API_BASE_URL; |
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL?.replace( |
||||
'/cgi-bin', |
||||
'/api', |
||||
); |
||||
|
||||
export default API_BASE_URL; |
||||
|
@ -1,3 +0,0 @@ |
||||
const IS_DEV_ENV: boolean = process.env.NODE_ENV !== 'production'; |
||||
|
||||
export default IS_DEV_ENV; |
@ -0,0 +1,9 @@ |
||||
export const UPLOAD_FILE_TYPES_ARRAY: ReadonlyArray< |
||||
[FileType, [string, string]] |
||||
> = [ |
||||
['iso', ['application/x-cd-image', 'ISO (optical disc)']], |
||||
['other', ['text/plain', 'Other file type']], |
||||
['script', ['text/plain', 'Script (program)']], |
||||
]; |
||||
export const UPLOAD_FILE_TYPES: ReadonlyMap<FileType, [string, string]> = |
||||
new Map(UPLOAD_FILE_TYPES_ARRAY); |
@ -0,0 +1,6 @@ |
||||
import { EmotionCache } from '@emotion/react'; |
||||
import createCache from '@emotion/cache'; |
||||
|
||||
const createEmotionCache = (): EmotionCache => createCache({ key: 'css' }); |
||||
|
||||
export default createEmotionCache; |
@ -1,5 +1,4 @@ |
||||
const fetchJSON = <T>(...args: [RequestInfo, RequestInit?]): Promise<T> => { |
||||
return fetch(...args).then((response: Response) => response.json()); |
||||
}; |
||||
const fetchJSON = <T>(...args: [RequestInfo, RequestInit?]): Promise<T> => |
||||
fetch(...args).then((response: Response) => response.json()); |
||||
|
||||
export default fetchJSON; |
||||
|
@ -1,12 +1,11 @@ |
||||
/* eslint-disable @typescript-eslint/no-explicit-any */ |
||||
const putFetch = <T>(uri: string, data: T): Promise<any> => { |
||||
return fetch(uri, { |
||||
const putFetch = <T>(uri: string, data: T): Promise<any> => |
||||
fetch(uri, { |
||||
method: 'PUT', |
||||
headers: { |
||||
'Content-Type': 'application/json', |
||||
}, |
||||
body: JSON.stringify(data), |
||||
}); |
||||
}; |
||||
|
||||
export default putFetch; |
||||
|
@ -1,7 +1,4 @@ |
||||
const hostsSanitizer = ( |
||||
data: Array<AnvilStatusHost>, |
||||
): Array<AnvilStatusHost> => { |
||||
return data?.filter((host) => host.host_uuid); |
||||
}; |
||||
const hostsSanitizer = (data: Array<AnvilStatusHost>): Array<AnvilStatusHost> => |
||||
data?.filter((host) => host.host_uuid); |
||||
|
||||
export default hostsSanitizer; |
||||
|
@ -0,0 +1,7 @@ |
||||
import { Axios } from 'axios'; |
||||
|
||||
const mainAxiosInstance = new Axios({ |
||||
baseURL: process.env.NEXT_PUBLIC_API_URL?.replace('/cgi-bin', '/api'), |
||||
}); |
||||
|
||||
export default mainAxiosInstance; |
@ -1 +0,0 @@ |
||||
self.__BUILD_MANIFEST=function(e){return{__rewrites:{beforeFiles:[],afterFiles:[],fallback:[]},"/":[e,"static/chunks/291-3b847a192c168e5d.js","static/chunks/pages/index-a78fc9f55265c4ea.js"],"/_error":["static/chunks/pages/_error-2280fa386d040b66.js"],"/server":[e,"static/chunks/145-170d45ccc7e94584.js","static/chunks/pages/server-b964c24fd9a69b1e.js"],sortedPages:["/","/_app","/_error","/server"]}}("static/chunks/321-7f3df35ed02396a1.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB(); |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue