Merge pull request #223 from Tsu-ba-me/fs-page

Web UI: add File Manager page
main
Digimer 3 years ago committed by GitHub
commit a16e626ede
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 25
      Anvil/Tools/Striker.pm
  2. 2
      Makefile.am
  3. 4
      anvil.spec.in
  4. 1
      configure.ac
  5. 2
      html/skins/alteeve/main.html
  6. 4
      striker-ui-api/.gitignore
  7. 6
      striker-ui-api/.prettierrc.json
  8. 6
      striker-ui-api/Makefile.am
  9. 18
      striker-ui-api/app.js
  10. 7
      striker-ui-api/index.js
  11. 94
      striker-ui-api/lib/accessDB.js
  12. 3
      striker-ui-api/lib/consts/API_ROOT_PATH.js
  13. 34
      striker-ui-api/lib/consts/SERVER_PATHS.js
  14. 3
      striker-ui-api/lib/consts/SERVER_PORT.js
  15. 29
      striker-ui-api/lib/request_handlers/files/buildGetFiles.js
  16. 25
      striker-ui-api/lib/request_handlers/files/getFileDetail.js
  17. 13
      striker-ui-api/lib/request_handlers/files/getFilesOverview.js
  18. 16
      striker-ui-api/middlewares/uploadSharedFiles.js
  19. 2
      striker-ui-api/out/index.js
  20. 7960
      striker-ui-api/package-lock.json
  21. 26
      striker-ui-api/package.json
  22. 17
      striker-ui-api/routes/echo.js
  23. 194
      striker-ui-api/routes/files.js
  24. 29
      striker-ui-api/webpack.config.js
  25. 2
      striker-ui/.eslintrc.json
  26. 5
      striker-ui/.gitignore
  27. 4
      striker-ui/.lintstagedrc.json
  28. 12
      striker-ui/Makefile.am
  29. 174
      striker-ui/components/AnvilDrawer.tsx
  30. 16
      striker-ui/components/Anvils/Anvil.tsx
  31. 45
      striker-ui/components/Anvils/AnvilList.tsx
  32. 31
      striker-ui/components/Anvils/SelectedAnvil.tsx
  33. 4
      striker-ui/components/Anvils/index.tsx
  34. 88
      striker-ui/components/Bars/AllocationBar.tsx
  35. 14
      striker-ui/components/Bars/BorderLinearProgress.tsx
  36. 82
      striker-ui/components/Bars/ProgressBar.tsx
  37. 6
      striker-ui/components/CPU.tsx
  38. 68
      striker-ui/components/ConfirmDialog.tsx
  39. 26
      striker-ui/components/ContainedButton.tsx
  40. 42
      striker-ui/components/Decorator.tsx
  41. 226
      striker-ui/components/Display/FullSize.tsx
  42. 121
      striker-ui/components/Display/Preview.tsx
  43. 12
      striker-ui/components/Domain.tsx
  44. 105
      striker-ui/components/FileSystem/FileSystems.tsx
  45. 109
      striker-ui/components/FileSystem/FileSystemsHost.tsx
  46. 343
      striker-ui/components/Files/FileEditForm.tsx
  47. 166
      striker-ui/components/Files/FileInfo.tsx
  48. 80
      striker-ui/components/Files/FileList.tsx
  49. 243
      striker-ui/components/Files/FileUploadForm.tsx
  50. 148
      striker-ui/components/Files/Files.tsx
  51. 3
      striker-ui/components/Files/index.tsx
  52. 52
      striker-ui/components/Header.tsx
  53. 198
      striker-ui/components/Hosts/AnvilHost.tsx
  54. 4
      striker-ui/components/Hosts/index.tsx
  55. 19
      striker-ui/components/IconButton/IconButton.tsx
  56. 3
      striker-ui/components/IconButton/index.tsx
  57. 6
      striker-ui/components/Memory.tsx
  58. 101
      striker-ui/components/MessageBox.tsx
  59. 61
      striker-ui/components/Network/Network.tsx
  60. 35
      striker-ui/components/OutlinedInput/OutlinedInput.tsx
  61. 3
      striker-ui/components/OutlinedInput/index.tsx
  62. 31
      striker-ui/components/OutlinedInputLabel/OutlinedInputLabel.tsx
  63. 3
      striker-ui/components/OutlinedInputLabel/index.tsx
  64. 36
      striker-ui/components/Panels/InnerPanel.tsx
  65. 42
      striker-ui/components/Panels/InnerPanelHeader.tsx
  66. 83
      striker-ui/components/Panels/Panel.tsx
  67. 49
      striker-ui/components/Panels/PanelHeader.tsx
  68. 5
      striker-ui/components/Panels/index.tsx
  69. 162
      striker-ui/components/Resource/ResourceVolumes.tsx
  70. 14
      striker-ui/components/Resource/index.tsx
  71. 306
      striker-ui/components/Servers.tsx
  72. 79
      striker-ui/components/SharedStorage/SharedStorage.tsx
  73. 96
      striker-ui/components/SharedStorage/SharedStorageHost.tsx
  74. 41
      striker-ui/components/Spinner.tsx
  75. 8
      striker-ui/components/Storage.tsx
  76. 72
      striker-ui/components/Text/BodyText.tsx
  77. 18
      striker-ui/components/Text/HeaderText.tsx
  78. 9
      striker-ui/lib/consts/API_BASE_URL.ts
  79. 2
      striker-ui/lib/consts/ICONS.ts
  80. 3
      striker-ui/lib/consts/IS_DEV_ENV.ts
  81. 9
      striker-ui/lib/consts/UPLOAD_FILE_TYPES.ts
  82. 6
      striker-ui/lib/create_emotion_cache/createEmotionCache.ts
  83. 5
      striker-ui/lib/fetchers/fetchJSON.ts
  84. 15
      striker-ui/lib/fetchers/periodicFetch.ts
  85. 5
      striker-ui/lib/fetchers/putFetch.ts
  86. 7
      striker-ui/lib/sanitizers/hostsSanitizer.ts
  87. 7
      striker-ui/lib/singletons/mainAxiosInstance.ts
  88. 1
      striker-ui/out/_next/static/QSRiaQhndGNrvMGSHQWys/_buildManifest.js
  89. 54
      striker-ui/out/_next/static/chunks/100-9ff20afbe31b0308.js
  90. 1
      striker-ui/out/_next/static/chunks/145-170d45ccc7e94584.js
  91. 1
      striker-ui/out/_next/static/chunks/291-3b847a192c168e5d.js
  92. 1
      striker-ui/out/_next/static/chunks/321-7f3df35ed02396a1.js
  93. 1
      striker-ui/out/_next/static/chunks/341-ed199b51c55f8ab0.js
  94. 1
      striker-ui/out/_next/static/chunks/478-248e83c4b5a23f77.js
  95. 1
      striker-ui/out/_next/static/chunks/616-c53b13a2808f9266.js
  96. 101
      striker-ui/out/_next/static/chunks/806-00fc7c4cf25438d2.js
  97. 1
      striker-ui/out/_next/static/chunks/pages/_app-572626eaa2005295.js
  98. 1
      striker-ui/out/_next/static/chunks/pages/_app-ab8d7853a59249fa.js
  99. 1
      striker-ui/out/_next/static/chunks/pages/file-manager-79f15e1c3d710546.js
  100. 1
      striker-ui/out/_next/static/chunks/pages/index-1071b3949e28b1b6.js
  101. Some files were not shown because too many files have changed in this diff Show More

@ -201,6 +201,31 @@ sub check_httpd_conf
$write_shell_call .= "set ".$last_rewriterule_arg."[3] [L]\n"; $write_shell_call .= "set ".$last_rewriterule_arg."[3] [L]\n";
} }
# Attempt to setup forwarding from the Apache server to the Striker UI API.
$augtool_path = "/files".$anvil->data->{path}{data}{httpd_conf}."/Location[arg='\"/api\"']";
$read_shell_call = $anvil->data->{path}{exe}{augtool}." <<EOF\nmatch ".$augtool_path."\nquit\nEOF\n";
($shell_output, $shell_return_code) = $anvil->System->call({ shell_call => $read_shell_call });
$anvil->Log->variables({ source => $THIS_FILE, line => __LINE__, level => $debug, list => {
shell_call => $read_shell_call,
shell_output => $shell_output,
shell_return_code => $shell_return_code
} });
if (($shell_return_code == 0) and (not $shell_output =~ /^\//))
{
$is_write = 1;
my $new_ifmodule_directive = $augtool_path."/IfModule[arg='proxy_module']";
my $striker_ui_api_url = "http://localhost:8080/api";
$write_shell_call .= "set ".$augtool_path =~ s/(Location)[^\s]+$/$1/r."[last()+1]/arg[1] '\"/api\"'\n";
$write_shell_call .= "set ".$augtool_path."/IfModule[last()+1]/arg[1] 'proxy_module'\n";
$write_shell_call .= "set ".$new_ifmodule_directive."/directive[last()+1] 'ProxyPass'\n";
$write_shell_call .= "set ".$new_ifmodule_directive."/directive[.='ProxyPass']/arg[1] '".$striker_ui_api_url."'\n";
}
if ($is_write) if ($is_write)
{ {
$write_shell_call .= "save\nquit\nEOF\n"; $write_shell_call .= "save\nquit\nEOF\n";

@ -22,7 +22,7 @@ ACLOCAL_AMFLAGS = -I m4
SUBDIRS = Anvil cgi-bin html journald.conf.d man ocf \ SUBDIRS = Anvil cgi-bin html journald.conf.d man ocf \
pxe scancore-agents scripts share striker-ui \ pxe scancore-agents scripts share striker-ui \
tools units striker-ui-api tools units
anvilconfdir = ${sysconfdir}/anvil anvilconfdir = ${sysconfdir}/anvil

@ -132,6 +132,7 @@ Requires: gdm
Requires: gnome-terminal Requires: gnome-terminal
Requires: httpd Requires: httpd
Requires: nmap Requires: nmap
Requires: nodejs
Requires: openssh-askpass Requires: openssh-askpass
Requires: postgresql-server Requires: postgresql-server
Requires: syslinux Requires: syslinux
@ -241,6 +242,8 @@ systemctl enable anvil-daemon.service
systemctl restart anvil-daemon.service systemctl restart anvil-daemon.service
systemctl enable scancore.service systemctl enable scancore.service
systemctl restart scancore.service systemctl restart scancore.service
systemctl enable striker-ui-api.service
systemctl restart striker-ui-api.service
%post striker %post striker
@ -373,6 +376,7 @@ fi
%{_sbindir}/* %{_sbindir}/*
%{_sysconfdir}/anvil/anvil.version %{_sysconfdir}/anvil/anvil.version
%{_datadir}/perl5/* %{_datadir}/perl5/*
%{_datadir}/striker-ui-api/*
%{_mandir}/* %{_mandir}/*
%files striker %files striker

@ -166,6 +166,7 @@ AC_CONFIG_FILES([Makefile
scripts/Makefile scripts/Makefile
share/Makefile share/Makefile
striker-ui/Makefile striker-ui/Makefile
striker-ui-api/Makefile
tools/Makefile tools/Makefile
units/Makefile]) units/Makefile])

@ -74,7 +74,7 @@
<!-- end files_button_off --> <!-- end files_button_off -->
<!-- start files_button_on --> <!-- start files_button_on -->
<a href="?files=true"><img src="#!data!skin::url!#/images/files_on.png" class="top_icon"></a> <a href="/file-manager"><img src="#!data!skin::url!#/images/files_on.png" class="top_icon"></a>
<!-- end files_button_on --> <!-- end files_button_on -->
<!-- start jobs_button_off --> <!-- start jobs_button_off -->

@ -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',
};

@ -13,7 +13,6 @@
"plugin:import/typescript", "plugin:import/typescript",
"plugin:import/warnings", "plugin:import/warnings",
"plugin:jsx-a11y/recommended", "plugin:jsx-a11y/recommended",
"plugin:prettier/recommended",
"plugin:react/recommended", "plugin:react/recommended",
"plugin:react-hooks/recommended", "plugin:react-hooks/recommended",
"prettier", "prettier",
@ -23,7 +22,6 @@
"@typescript-eslint", "@typescript-eslint",
"import", "import",
"jsx-a11y", "jsx-a11y",
"prettier",
"react", "react",
"react-hooks" "react-hooks"
], ],

@ -14,8 +14,9 @@
# production # production
/build /build
/out/* /out/*
!/out/index.html # The next 2 lines omits all HTML files but includes a specific HTML file; the order of 2 lines matters.
!/out/server.html !/out/*.html
/out/404.html
!/out/_next !/out/_next
# misc # misc

@ -1,4 +1,4 @@
{ {
"!(out/**/*)*.{js,jsx,ts,tsx}": "npm run lint -- --fix", "!(out/**/*)*.{js,jsx,ts,tsx}": "npm run eslint:base -- --fix",
"*.{json,md}": "prettier --write" "!(out/**/*)*.{js,json,jsx,md,ts,tsx}": "prettier --write"
} }

@ -7,8 +7,10 @@ nextbuilddir = .next
# List of paths relative to the build output directory. # List of paths relative to the build output directory.
# #
outindexpage = index.html outpages = \
outserverpage = server.html index.html \
server.html \
file-manager.html
outjsmodulesdir = _next outjsmodulesdir = _next
outimagesdir = pngs outimagesdir = pngs
@ -90,9 +92,7 @@ build: $(nodemodulesdir)
install-data-hook: install-data-hook:
-@echo "Place build output files." -@echo "Place build output files."
(cd $(srcdir)/$(nextoutdir); \ (cd $(srcdir)/$(nextoutdir); \
cp -r --no-preserve=mode \ cp -r --no-preserve=mode $(outpages) $(outjsmodulesdir) $(DESTDIR)/$(htmldir)/ \
$(outindexpage) $(outserverpage) $(outjsmodulesdir) \
$(DESTDIR)/$(htmldir)/ \
) )
-@echo "Create symlink to images to enable borrowing icon etc. without duplicating." -@echo "Create symlink to images to enable borrowing icon etc. without duplicating."
(cd $(DESTDIR)/$(htmldir); $(LN_S) skins/alteeve/images $(outimagesdir)) (cd $(DESTDIR)/$(htmldir); $(LN_S) skins/alteeve/images $(outimagesdir))
@ -100,7 +100,7 @@ install-data-hook:
uninstall-hook: uninstall-hook:
-@echo "Remove all installed files of the current module." -@echo "Remove all installed files of the current module."
(cd $(DESTDIR)/$(htmldir); \ (cd $(DESTDIR)/$(htmldir); \
rm -rf $(outindexpage) $(outserverpage) $(outjsmodulesdir) $(outimagesdir) \ rm -rf $(outpages) $(outjsmodulesdir) $(outimagesdir) \
) )
clean-local: clean-local:

@ -1,97 +1,105 @@
import { Divider, Drawer, List, ListItem, Box } from '@material-ui/core'; import { Box, Divider, Drawer, List, ListItem } from '@mui/material';
import { makeStyles, createStyles } from '@material-ui/core/styles'; import { styled } from '@mui/material/styles';
import DashboardIcon from '@material-ui/icons/Dashboard'; import DashboardIcon from '@mui/icons-material/Dashboard';
import { Dispatch, SetStateAction } from 'react'; import { Dispatch, SetStateAction } from 'react';
import { BodyText, HeaderText } from './Text'; import { BodyText, HeaderText } from './Text';
import { ICONS, ICON_SIZE } from '../lib/consts/ICONS'; import { ICONS, ICON_SIZE } from '../lib/consts/ICONS';
import { DIVIDER, GREY } from '../lib/consts/DEFAULT_THEME'; 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 { interface DrawerProps {
open: boolean; open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>; setOpen: Dispatch<SetStateAction<boolean>>;
} }
const useStyles = makeStyles(() => const AnvilDrawer = ({ open, setOpen }: DrawerProps): JSX.Element => (
createStyles({ <StyledDrawer
list: { BackdropProps={{ invisible: true }}
width: '200px', anchor="left"
}, open={open}
divider: { onClose={() => setOpen(!open)}
background: DIVIDER, >
}, <div role="presentation">
text: { <List className={classes.list}>
paddingTop: '.5em', <ListItem button>
paddingLeft: '1.5em', <HeaderText text="Admin" />
}, </ListItem>
dashboardButton: { <Divider className={classes.divider} />
paddingLeft: '.1em', <ListItem button component="a" href="/index.html">
}, <Box display="flex" flexDirection="row" width="100%">
dashboardIcon: { <Box className={classes.dashboardButton}>
fontSize: '2.3em', <DashboardIcon className={classes.dashboardIcon} />
color: GREY, </Box>
}, <Box flexGrow={1} className={classes.text}>
}), <BodyText text="Dashboard" />
);
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>
</Box> </Box>
</ListItem> </Box>
{ICONS.map( </ListItem>
(icon): JSX.Element => ( {ICONS.map(
<ListItem (icon): JSX.Element => (
button <ListItem
key={icon.image} button
component="a" key={icon.image}
href={ component="a"
icon.uri.search(/^https?:/) !== -1 href={
? icon.uri icon.uri.search(/^https?:/) !== -1
: `${process.env.NEXT_PUBLIC_API_URL}${icon.uri}` ? icon.uri
} : `${process.env.NEXT_PUBLIC_API_URL}${icon.uri}`
> }
<Box display="flex" flexDirection="row" width="100%"> >
<Box> <Box display="flex" flexDirection="row" width="100%">
<img <Box>
alt="" <img
key="icon" alt=""
src={icon.image} key="icon"
// eslint-disable-next-line react/jsx-props-no-spreading src={icon.image}
{...ICON_SIZE} // eslint-disable-next-line react/jsx-props-no-spreading
/> {...ICON_SIZE}
</Box> />
<Box flexGrow={1} className={classes.text}>
<BodyText text={icon.text} />
</Box>
</Box> </Box>
</ListItem> <Box flexGrow={1} className={classes.text}>
), <BodyText text={icon.text} />
)} </Box>
</List> </Box>
</div> </ListItem>
</Drawer> ),
); )}
}; </List>
</div>
</StyledDrawer>
);
export default AnvilDrawer; export default AnvilDrawer;

@ -1,15 +1,11 @@
import { BodyText } from '../Text'; import { BodyText } from '../Text';
import anvilState from '../../lib/consts/ANVILS'; import anvilState from '../../lib/consts/ANVILS';
const Anvil = ({ anvil }: { anvil: AnvilListItem }): JSX.Element => { const Anvil = ({ anvil }: { anvil: AnvilListItem }): JSX.Element => (
return ( <>
<> <BodyText text={anvil.anvil_name} />
<BodyText text={anvil.anvil_name} /> <BodyText text={anvilState.get(anvil.anvil_state) || 'State unavailable'} />
<BodyText </>
text={anvilState.get(anvil.anvil_state) || 'State unavailable'} );
/>
</>
);
};
export default Anvil; export default Anvil;

@ -1,6 +1,6 @@
import { useContext, useEffect } from 'react'; import { useContext, useEffect } from 'react';
import { makeStyles } from '@material-ui/core/styles'; import { styled } from '@mui/material/styles';
import { List, Box, Divider, ListItem } from '@material-ui/core'; import { List, Box, Divider, ListItem } from '@mui/material';
import { import {
HOVER, HOVER,
DIVIDER, DIVIDER,
@ -11,8 +11,17 @@ import { AnvilContext } from '../AnvilContext';
import sortAnvils from './sortAnvils'; import sortAnvils from './sortAnvils';
import Decorator, { Colours } from '../Decorator'; import Decorator, { Colours } from '../Decorator';
const useStyles = makeStyles((theme) => ({ const PREFIX = 'AnvilList';
root: {
const classes = {
root: `${PREFIX}-root`,
divider: `${PREFIX}-divider`,
button: `${PREFIX}-button`,
anvil: `${PREFIX}-anvil`,
};
const StyledDiv = styled('div')(({ theme }) => ({
[`& .${classes.root}`]: {
width: '100%', width: '100%',
overflow: 'auto', overflow: 'auto',
height: '30vh', height: '30vh',
@ -22,16 +31,19 @@ const useStyles = makeStyles((theme) => ({
overflow: 'hidden', overflow: 'hidden',
}, },
}, },
divider: {
background: DIVIDER, [`& .${classes.divider}`]: {
backgroundColor: DIVIDER,
}, },
button: {
[`& .${classes.button}`]: {
'&:hover': { '&:hover': {
backgroundColor: HOVER, backgroundColor: HOVER,
}, },
paddingLeft: 0, paddingLeft: 0,
}, },
anvil: {
[`& .${classes.anvil}`]: {
paddingLeft: 0, paddingLeft: 0,
}, },
})); }));
@ -51,16 +63,19 @@ const selectDecorator = (state: string): Colours => {
const AnvilList = ({ list }: { list: AnvilListItem[] }): JSX.Element => { const AnvilList = ({ list }: { list: AnvilListItem[] }): JSX.Element => {
const { uuid, setAnvilUuid } = useContext(AnvilContext); const { uuid, setAnvilUuid } = useContext(AnvilContext);
const classes = useStyles();
useEffect(() => { useEffect(() => {
if (uuid === '') setAnvilUuid(sortAnvils(list)[0].anvil_uuid); if (uuid === '') setAnvilUuid(sortAnvils(list)[0].anvil_uuid);
}, [uuid, list, setAnvilUuid]); }, [uuid, list, setAnvilUuid]);
return ( return (
<List component="nav" className={classes.root} aria-label="mailbox folders"> <StyledDiv>
{sortAnvils(list).map((anvil) => { <List
return ( component="nav"
className={classes.root}
aria-label="mailbox folders"
>
{sortAnvils(list).map((anvil) => (
<> <>
<Divider className={classes.divider} /> <Divider className={classes.divider} />
<ListItem <ListItem
@ -79,9 +94,9 @@ const AnvilList = ({ list }: { list: AnvilListItem[] }): JSX.Element => {
</Box> </Box>
</ListItem> </ListItem>
</> </>
); ))}
})} </List>
</List> </StyledDiv>
); );
}; };

@ -1,21 +1,25 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { Switch, Box } from '@material-ui/core'; import Box from '@mui/material/Box';
import { makeStyles } from '@material-ui/core/styles'; import Switch from '@mui/material/Switch';
import { styled } from '@mui/material/styles';
import { HeaderText } from '../Text'; import { HeaderText } from '../Text';
import { SELECTED_ANVIL } from '../../lib/consts/DEFAULT_THEME';
import anvilState from '../../lib/consts/ANVILS'; import anvilState from '../../lib/consts/ANVILS';
import { AnvilContext } from '../AnvilContext'; import { AnvilContext } from '../AnvilContext';
import Decorator, { Colours } from '../Decorator'; import Decorator, { Colours } from '../Decorator';
import putFetch from '../../lib/fetchers/putFetch'; import putFetch from '../../lib/fetchers/putFetch';
const useStyles = makeStyles(() => ({ const PREFIX = 'SelectedAnvil';
root: {
width: '100%', const classes = {
'&:hover $child': { anvilName: `${PREFIX}-anvilName`,
backgroundColor: SELECTED_ANVIL, };
},
}, const StyledBox = styled(Box)(() => ({
anvilName: { display: 'flex',
flexDirection: 'row',
width: '100%',
[`& .${classes.anvilName}`]: {
paddingLeft: 0, paddingLeft: 0,
}, },
})); }));
@ -42,14 +46,13 @@ const isAnvilOn = (anvil: AnvilListItem): boolean =>
const SelectedAnvil = ({ list }: { list: AnvilListItem[] }): JSX.Element => { const SelectedAnvil = ({ list }: { list: AnvilListItem[] }): JSX.Element => {
const { uuid } = useContext(AnvilContext); const { uuid } = useContext(AnvilContext);
const classes = useStyles();
const index = list.findIndex( const index = list.findIndex(
(anvil: AnvilListItem) => anvil.anvil_uuid === uuid, (anvil: AnvilListItem) => anvil.anvil_uuid === uuid,
); );
return ( return (
<Box display="flex" flexDirection="row" width="100%"> <StyledBox>
{uuid !== '' && ( {uuid !== '' && (
<> <>
<Box p={1}> <Box p={1}>
@ -76,7 +79,7 @@ const SelectedAnvil = ({ list }: { list: AnvilListItem[] }): JSX.Element => {
</Box> </Box>
</> </>
)} )}
</Box> </StyledBox>
); );
}; };

@ -1,5 +1,5 @@
import { Panel } from '../Panels'; import { Panel } from '../Panels';
import PeriodicFetch from '../../lib/fetchers/periodicFetch'; import periodicFetch from '../../lib/fetchers/periodicFetch';
import SelectedAnvil from './SelectedAnvil'; import SelectedAnvil from './SelectedAnvil';
import AnvilList from './AnvilList'; import AnvilList from './AnvilList';
@ -9,7 +9,7 @@ const Anvils = ({ list }: { list: AnvilList | undefined }): JSX.Element => {
const anvils: AnvilListItem[] = []; const anvils: AnvilListItem[] = [];
list?.anvils.forEach((anvil: AnvilListItem) => { list?.anvils.forEach((anvil: AnvilListItem) => {
const { data } = PeriodicFetch<AnvilStatus>( const { data } = periodicFetch<AnvilStatus>(
`${process.env.NEXT_PUBLIC_API_URL}/get_status?anvil_uuid=${anvil.anvil_uuid}`, `${process.env.NEXT_PUBLIC_API_URL}/get_status?anvil_uuid=${anvil.anvil_uuid}`,
); );
anvils.push({ anvils.push({

@ -1,61 +1,65 @@
import { makeStyles, withStyles } from '@material-ui/core/styles'; import { LinearProgress } from '@mui/material';
import { LinearProgress } from '@material-ui/core'; import { styled } from '@mui/material/styles';
import { import {
PURPLE, PURPLE,
RED, RED,
BLUE, BLUE,
PANEL_BACKGROUND,
BORDER_RADIUS, BORDER_RADIUS,
} from '../../lib/consts/DEFAULT_THEME'; } from '../../lib/consts/DEFAULT_THEME';
import BorderLinearProgress from './BorderLinearProgress';
const breakpointWarning = 70; const PREFIX = 'AllocationBar';
const breakpointAlert = 90;
const BorderLinearProgress = withStyles({ const classes = {
root: { barOk: `${PREFIX}-barOk`,
height: '1em', barWarning: `${PREFIX}-barWarning`,
borderRadius: BORDER_RADIUS, barAlert: `${PREFIX}-barAlert`,
}, underline: `${PREFIX}-underline`,
colorPrimary: { };
backgroundColor: PANEL_BACKGROUND,
},
bar: {
borderRadius: BORDER_RADIUS,
},
})(LinearProgress);
const useStyles = makeStyles(() => ({ const StyledDiv = styled('div')(() => ({
barOk: { [`& .${classes.barOk}`]: {
backgroundColor: BLUE, backgroundColor: BLUE,
}, },
barWarning: {
[`& .${classes.barWarning}`]: {
backgroundColor: PURPLE, backgroundColor: PURPLE,
}, },
barAlert: {
[`& .${classes.barAlert}`]: {
backgroundColor: RED, backgroundColor: RED,
}, },
[`& .${classes.underline}`]: {
borderRadius: BORDER_RADIUS,
},
})); }));
const AllocationBar = ({ allocated }: { allocated: number }): JSX.Element => { const breakpointWarning = 70;
const classes = useStyles(); const breakpointAlert = 90;
return (
<> const AllocationBar = ({ allocated }: { allocated: number }): JSX.Element => (
<BorderLinearProgress <StyledDiv>
classes={{ <BorderLinearProgress
bar: classes={{
/* eslint-disable no-nested-ternary */ bar:
allocated > breakpointWarning /* eslint-disable no-nested-ternary */
? allocated > breakpointAlert allocated > breakpointWarning
? classes.barAlert ? allocated > breakpointAlert
: classes.barWarning ? classes.barAlert
: classes.barOk, : classes.barWarning
}} : classes.barOk,
variant="determinate" }}
value={allocated} variant="determinate"
/> value={allocated}
<LinearProgress variant="determinate" value={0} /> />
</> <LinearProgress
); className={classes.underline}
}; variant="determinate"
value={0}
/>
</StyledDiv>
);
export default AllocationBar; 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 '@mui/material';
import { LinearProgress } from '@material-ui/core'; import { styled } from '@mui/material/styles';
import {
PURPLE,
BLUE,
PANEL_BACKGROUND,
BORDER_RADIUS,
} from '../../lib/consts/DEFAULT_THEME';
const completed = 100; import { PURPLE, BLUE, BORDER_RADIUS } from '../../lib/consts/DEFAULT_THEME';
import BorderLinearProgress from './BorderLinearProgress';
const BorderLinearProgress = withStyles({ const PREFIX = 'ProgressBar';
root: {
height: '1em', const classes = {
borderRadius: BORDER_RADIUS, barOk: `${PREFIX}-barOk`,
}, barInProgress: `${PREFIX}-barInProgress`,
colorPrimary: { underline: `${PREFIX}-underline`,
backgroundColor: PANEL_BACKGROUND, };
},
bar: {
borderRadius: BORDER_RADIUS,
},
})(LinearProgress);
const useStyles = makeStyles(() => ({ const StyledDiv = styled('div')(() => ({
barOk: { [`& .${classes.barOk}`]: {
backgroundColor: BLUE, backgroundColor: BLUE,
}, },
barInProgress: {
[`& .${classes.barInProgress}`]: {
backgroundColor: PURPLE, backgroundColor: PURPLE,
}, },
[`& .${classes.underline}`]: {
borderRadius: BORDER_RADIUS,
},
})); }));
const completed = 100;
const ProgressBar = ({ const ProgressBar = ({
progressPercentage, progressPercentage,
}: { }: {
progressPercentage: number; progressPercentage: number;
}): JSX.Element => { }): JSX.Element => (
const classes = useStyles(); <StyledDiv>
return ( <BorderLinearProgress
<> classes={{
<BorderLinearProgress bar:
classes={{ progressPercentage < completed
bar: ? classes.barInProgress
progressPercentage < completed : classes.barOk,
? classes.barInProgress }}
: classes.barOk, variant="determinate"
}} value={progressPercentage}
variant="determinate" />
value={progressPercentage} <LinearProgress
/> className={classes.underline}
<LinearProgress variant="determinate" value={0} /> variant="determinate"
</> value={0}
); />
}; </StyledDiv>
);
export default ProgressBar; export default ProgressBar;

@ -1,15 +1,15 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { Box } from '@material-ui/core'; import { Box } from '@mui/material';
import { Panel } from './Panels'; import { Panel } from './Panels';
import { HeaderText, BodyText } from './Text'; import { HeaderText, BodyText } from './Text';
import PeriodicFetch from '../lib/fetchers/periodicFetch'; import periodicFetch from '../lib/fetchers/periodicFetch';
import { AnvilContext } from './AnvilContext'; import { AnvilContext } from './AnvilContext';
import Spinner from './Spinner'; import Spinner from './Spinner';
const CPU = (): JSX.Element => { const CPU = (): JSX.Element => {
const { uuid } = useContext(AnvilContext); const { uuid } = useContext(AnvilContext);
const { data, isLoading } = PeriodicFetch<AnvilCPU>( const { data, isLoading } = periodicFetch<AnvilCPU>(
`${process.env.NEXT_PUBLIC_API_URL}/get_cpu?anvil_uuid=${uuid}`, `${process.env.NEXT_PUBLIC_API_URL}/get_cpu?anvil_uuid=${uuid}`,
); );

@ -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,4 +1,4 @@
import { makeStyles } from '@material-ui/core/styles'; import { styled } from '@mui/material/styles';
import { import {
BLUE, BLUE,
GREY, GREY,
@ -7,31 +7,41 @@ import {
BORDER_RADIUS, BORDER_RADIUS,
} from '../lib/consts/DEFAULT_THEME'; } from '../lib/consts/DEFAULT_THEME';
export type Colours = 'ok' | 'off' | 'error' | 'warning'; const PREFIX = 'Decorator';
const useStyles = makeStyles(() => ({ const classes = {
decorator: { ok: `${PREFIX}-ok`,
width: '1.4em', warning: `${PREFIX}-warning`,
height: '100%', error: `${PREFIX}-error`,
borderRadius: BORDER_RADIUS, off: `${PREFIX}-off`,
}, };
ok: {
const StyledDiv = styled('div')(() => ({
width: '1.4em',
height: '100%',
borderRadius: BORDER_RADIUS,
[`&.${classes.ok}`]: {
backgroundColor: BLUE, backgroundColor: BLUE,
}, },
warning: {
[`&.${classes.warning}`]: {
backgroundColor: PURPLE, backgroundColor: PURPLE,
}, },
error: {
[`&.${classes.error}`]: {
backgroundColor: RED, backgroundColor: RED,
}, },
off: {
[`&.${classes.off}`]: {
backgroundColor: GREY, backgroundColor: GREY,
}, },
})); }));
const Decorator = ({ colour }: { colour: Colours }): JSX.Element => { export type Colours = 'ok' | 'off' | 'error' | 'warning';
const classes = useStyles();
return <div className={`${classes.decorator} ${classes[colour]}`} />; const Decorator = ({ colour }: { colour: Colours }): JSX.Element => (
}; <StyledDiv className={classes[colour]} />
);
export default Decorator; export default Decorator;

@ -1,10 +1,16 @@
import { useState, useRef, useEffect, Dispatch, SetStateAction } from 'react'; import { useState, useRef, useEffect, Dispatch, SetStateAction } from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { Box, Menu, MenuItem, Typography, Button } from '@material-ui/core'; import {
import { makeStyles } from '@material-ui/core/styles'; Box,
import CloseIcon from '@material-ui/icons/Close'; Button,
import KeyboardIcon from '@material-ui/icons/Keyboard'; IconButton,
import IconButton from '@material-ui/core/IconButton'; Menu,
MenuItem,
Typography,
} from '@mui/material';
import { styled } from '@mui/material/styles';
import CloseIcon from '@mui/icons-material/Close';
import KeyboardIcon from '@mui/icons-material/Keyboard';
import RFB from '@novnc/novnc/core/rfb'; import RFB from '@novnc/novnc/core/rfb';
import { Panel } from '../Panels'; import { Panel } from '../Panels';
import { BLACK, RED, TEXT } from '../../lib/consts/DEFAULT_THEME'; import { BLACK, RED, TEXT } from '../../lib/consts/DEFAULT_THEME';
@ -14,10 +20,21 @@ import putFetchWithTimeout from '../../lib/fetchers/putFetchWithTimeout';
import { HeaderText } from '../Text'; import { HeaderText } from '../Text';
import Spinner from '../Spinner'; import Spinner from '../Spinner';
const VncDisplay = dynamic(() => import('./VncDisplay'), { ssr: false }); const PREFIX = 'FullSize';
const classes = {
displayBox: `${PREFIX}-displayBox`,
spinnerBox: `${PREFIX}-spinnerBox`,
closeButton: `${PREFIX}-closeButton`,
keyboardButton: `${PREFIX}-keyboardButton`,
closeBox: `${PREFIX}-closeBox`,
buttonsBox: `${PREFIX}-buttonsBox`,
keysItem: `${PREFIX}-keysItem`,
buttonText: `${PREFIX}-buttonText`,
};
const useStyles = makeStyles(() => ({ const StyledDiv = styled('div')(() => ({
displayBox: { [`& .${classes.displayBox}`]: {
width: '75vw', width: '75vw',
height: '75vh', height: '75vh',
paddingTop: '1em', paddingTop: '1em',
@ -25,47 +42,56 @@ const useStyles = makeStyles(() => ({
paddingLeft: 0, paddingLeft: 0,
paddingRight: 0, paddingRight: 0,
}, },
spinnerBox: {
[`& .${classes.spinnerBox}`]: {
flexDirection: 'column', flexDirection: 'column',
width: '75vw', width: '75vw',
height: '75vh', height: '75vh',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
closeButton: {
[`& .${classes.closeButton}`]: {
borderRadius: 8, borderRadius: 8,
backgroundColor: RED, backgroundColor: RED,
'&:hover': { '&:hover': {
backgroundColor: RED, backgroundColor: RED,
}, },
}, },
keyboardButton: {
[`& .${classes.keyboardButton}`]: {
borderRadius: 8, borderRadius: 8,
backgroundColor: TEXT, backgroundColor: TEXT,
'&:hover': { '&:hover': {
backgroundColor: TEXT, backgroundColor: TEXT,
}, },
}, },
closeBox: {
[`& .${classes.closeBox}`]: {
paddingBottom: '1em', paddingBottom: '1em',
paddingLeft: '.7em', paddingLeft: '.7em',
paddingRight: 0, paddingRight: 0,
}, },
buttonsBox: {
[`& .${classes.buttonsBox}`]: {
paddingTop: 0, paddingTop: 0,
}, },
keysItem: {
[`& .${classes.keysItem}`]: {
backgroundColor: TEXT, backgroundColor: TEXT,
paddingRight: '3em', paddingRight: '3em',
'&:hover': { '&:hover': {
backgroundColor: TEXT, backgroundColor: TEXT,
}, },
}, },
buttonText: {
[`& .${classes.buttonText}`]: {
color: BLACK, color: BLACK,
}, },
})); }));
const VncDisplay = dynamic(() => import('./VncDisplay'), { ssr: false });
interface FullSizeProps { interface FullSizeProps {
setMode: Dispatch<SetStateAction<boolean>>; setMode: Dispatch<SetStateAction<boolean>>;
uuid: string; uuid: string;
@ -89,7 +115,6 @@ const FullSize = ({
VncConnectionProps | undefined VncConnectionProps | undefined
>(undefined); >(undefined);
const [isError, setIsError] = useState<boolean>(false); const [isError, setIsError] = useState<boolean>(false);
const classes = useStyles();
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@ -145,56 +170,56 @@ const FullSize = ({
return ( return (
<Panel> <Panel>
<Box flexGrow={1}> <StyledDiv>
<HeaderText text={`Server: ${serverName}`} /> <Box flexGrow={1}>
</Box> <HeaderText text={`Server: ${serverName}`} />
{vncConnection ? ( </Box>
<Box display="flex" className={classes.displayBox}> {vncConnection ? (
<VncDisplay <Box display="flex" className={classes.displayBox}>
rfb={rfb} <VncDisplay
url={`${vncConnection.protocol}://${hostname.current}:${vncConnection.forward_port}`} rfb={rfb}
viewOnly={false} url={`${vncConnection.protocol}://${hostname.current}:${vncConnection.forward_port}`}
focusOnClick={false} viewOnly={false}
clipViewport={false} focusOnClick={false}
dragViewport={false} clipViewport={false}
scaleViewport dragViewport={false}
resizeSession scaleViewport
showDotCursor={false} resizeSession
background="" showDotCursor={false}
qualityLevel={6} background=""
compressionLevel={2} qualityLevel={6}
/> compressionLevel={2}
<Box> />
<Box className={classes.closeBox}> <Box>
<IconButton <Box className={classes.closeBox}>
className={classes.closeButton} <IconButton
style={{ color: TEXT }} className={classes.closeButton}
component="span" style={{ color: TEXT }}
onClick={() => { component="span"
handleClickClose(); onClick={() => {
setMode(true); handleClickClose();
}} setMode(true);
> }}
<CloseIcon /> >
</IconButton> <CloseIcon />
</Box> </IconButton>
<Box className={classes.closeBox}> </Box>
<IconButton <Box className={classes.closeBox}>
className={classes.keyboardButton} <IconButton
style={{ color: BLACK }} className={classes.keyboardButton}
component="span" style={{ color: BLACK }}
onClick={handleClick} component="span"
> onClick={handleClick}
<KeyboardIcon /> >
</IconButton> <KeyboardIcon />
<Menu </IconButton>
anchorEl={anchorEl} <Menu
keepMounted anchorEl={anchorEl}
open={Boolean(anchorEl)} keepMounted
onClose={() => setAnchorEl(null)} open={Boolean(anchorEl)}
> onClose={() => setAnchorEl(null)}
{keyCombinations.map(({ keys, scans }) => { >
return ( {keyCombinations.map(({ keys, scans }) => (
<MenuItem <MenuItem
onClick={() => handleSendKeys(scans)} onClick={() => handleSendKeys(scans)}
className={classes.keysItem} className={classes.keysItem}
@ -202,40 +227,45 @@ const FullSize = ({
> >
<Typography variant="subtitle1">{keys}</Typography> <Typography variant="subtitle1">{keys}</Typography>
</MenuItem> </MenuItem>
); ))}
})} </Menu>
</Menu> </Box>
</Box> </Box>
</Box> </Box>
</Box> ) : (
) : ( <Box display="flex" className={classes.spinnerBox}>
<Box display="flex" className={classes.spinnerBox}> {!isError ? (
{!isError ? ( <>
<> <HeaderText
<HeaderText text={`Establishing connection with ${serverName}`} /> text={`Establishing connection with ${serverName}`}
<HeaderText text="This may take a few minutes" /> />
<Spinner /> <HeaderText text="This may take a few minutes" />
</> <Spinner />
) : ( </>
<> ) : (
<Box style={{ paddingBottom: '2em' }}> <>
<HeaderText text="There was a problem connecting to the server, please try again" /> <Box style={{ paddingBottom: '2em' }}>
</Box> <HeaderText text="There was a problem connecting to the server, please try again" />
<Button </Box>
variant="contained" <Button
onClick={() => { variant="contained"
setIsError(false); onClick={() => {
}} setIsError(false);
style={{ textTransform: 'none' }} }}
> style={{ textTransform: 'none' }}
<Typography className={classes.buttonText} variant="subtitle1"> >
Reconnect <Typography
</Typography> className={classes.buttonText}
</Button> variant="subtitle1"
</> >
)} Reconnect
</Box> </Typography>
)} </Button>
</>
)}
</Box>
)}
</StyledDiv>
</Panel> </Panel>
); );
}; };

@ -1,55 +1,72 @@
import { Dispatch, SetStateAction, useEffect, useState } from 'react'; import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { Box } from '@material-ui/core'; import { Box, IconButton, styled } from '@mui/material';
import { makeStyles } from '@material-ui/core/styles'; import {
import IconButton from '@material-ui/core/IconButton'; DesktopWindows as DesktopWindowsIcon,
import DesktopWindowsIcon from '@material-ui/icons/DesktopWindows'; PowerOffOutlined as PowerOffOutlinedIcon,
import PowerOffOutlinedIcon from '@material-ui/icons/PowerOffOutlined'; } from '@mui/icons-material';
import { Panel } from '../Panels';
import { BLACK, GREY, TEXT } from '../../lib/consts/DEFAULT_THEME'; import { BLACK, GREY, TEXT } from '../../lib/consts/DEFAULT_THEME';
import { Panel } from '../Panels';
import { HeaderText } from '../Text'; import { HeaderText } from '../Text';
interface PreviewProps { const PREFIX = 'Preview';
setMode: Dispatch<SetStateAction<boolean>>;
uuid: string; const classes = {
serverName: string | string[] | undefined; displayBox: `${PREFIX}-displayBox`,
} fullScreenButton: `${PREFIX}-fullScreenButton`,
fullScreenBox: `${PREFIX}-fullScreenBox`,
imageButton: `${PREFIX}-imageButton`,
powerOffIcon: `${PREFIX}-powerOffIcon`,
previewImage: `${PREFIX}-previewImage`,
};
const useStyles = makeStyles(() => ({ const StyledDiv = styled('div')(() => ({
displayBox: { [`& .${classes.displayBox}`]: {
padding: 0, padding: 0,
paddingTop: '.7em', paddingTop: '.7em',
width: '100%', width: '100%',
}, },
fullScreenButton: {
[`& .${classes.fullScreenButton}`]: {
borderRadius: 8, borderRadius: 8,
backgroundColor: TEXT, backgroundColor: TEXT,
'&:hover': { '&:hover': {
backgroundColor: TEXT, backgroundColor: TEXT,
}, },
}, },
fullScreenBox: {
[`& .${classes.fullScreenBox}`]: {
paddingLeft: '1em', paddingLeft: '1em',
padding: 0, padding: 0,
}, },
imageButton: {
[`& .${classes.imageButton}`]: {
padding: 0, padding: 0,
color: TEXT, color: TEXT,
}, },
powerOffIcon: {
[`& .${classes.powerOffIcon}`]: {
borderRadius: 8, borderRadius: 8,
padding: 0, padding: 0,
color: GREY, color: GREY,
width: '100%', width: '100%',
height: '100%', height: '100%',
}, },
previewImage: {
[`& .${classes.previewImage}`]: {
width: '100%', width: '100%',
height: '100%', height: '100%',
}, },
})); }));
interface PreviewProps {
setMode: Dispatch<SetStateAction<boolean>>;
uuid: string;
serverName: string | string[] | undefined;
}
const Preview = ({ setMode, uuid, serverName }: PreviewProps): JSX.Element => { const Preview = ({ setMode, uuid, serverName }: PreviewProps): JSX.Element => {
const classes = useStyles();
const [preview, setPreview] = useState<string>(); const [preview, setPreview] = useState<string>();
useEffect(() => { useEffect(() => {
@ -74,40 +91,42 @@ const Preview = ({ setMode, uuid, serverName }: PreviewProps): JSX.Element => {
return ( return (
<Panel> <Panel>
<Box flexGrow={1}> <StyledDiv>
<HeaderText text={`Server: ${serverName}`} /> <Box flexGrow={1}>
</Box> <HeaderText text={`Server: ${serverName}`} />
<Box display="flex" className={classes.displayBox}>
<Box>
<IconButton
className={classes.imageButton}
style={{ color: BLACK }}
component="span"
onClick={() => setMode(false)}
>
{!preview ? (
<PowerOffOutlinedIcon className={classes.powerOffIcon} />
) : (
<img
alt=""
key="preview"
src={`data:image/png;base64,${preview}`}
className={classes.previewImage}
/>
)}
</IconButton>
</Box> </Box>
<Box className={classes.fullScreenBox}> <Box display="flex" className={classes.displayBox}>
<IconButton <Box>
className={classes.fullScreenButton} <IconButton
style={{ color: BLACK }} className={classes.imageButton}
component="span" style={{ color: BLACK }}
onClick={() => setMode(false)} component="span"
> onClick={() => setMode(false)}
<DesktopWindowsIcon /> >
</IconButton> {!preview ? (
<PowerOffOutlinedIcon className={classes.powerOffIcon} />
) : (
<img
alt=""
key="preview"
src={`data:image/png;base64,${preview}`}
className={classes.previewImage}
/>
)}
</IconButton>
</Box>
<Box className={classes.fullScreenBox}>
<IconButton
className={classes.fullScreenButton}
style={{ color: BLACK }}
component="span"
onClick={() => setMode(false)}
>
<DesktopWindowsIcon />
</IconButton>
</Box>
</Box> </Box>
</Box> </StyledDiv>
</Panel> </Panel>
); );
}; };

@ -1,12 +1,10 @@
import { Panel } from './Panels'; import { Panel } from './Panels';
import { HeaderText } from './Text'; import { HeaderText } from './Text';
const Domain = (): JSX.Element => { const Domain = (): JSX.Element => (
return ( <Panel>
<Panel> <HeaderText text="Domain Settings" />
<HeaderText text="Domain Settings" /> </Panel>
</Panel> );
);
};
export default Domain; export default Domain;

@ -1,21 +1,29 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { Box } from '@material-ui/core'; import { Box } from '@mui/material';
import { makeStyles } from '@material-ui/core/styles'; import { styled } from '@mui/material/styles';
import { BodyText, HeaderText } from '../Text'; import { BodyText, HeaderText } from '../Text';
import { Panel, InnerPanel, PanelHeader } from '../Panels'; import { Panel, InnerPanel, InnerPanelHeader } from '../Panels';
import SharedStorageHost from './FileSystemsHost'; import SharedStorageHost from './FileSystemsHost';
import PeriodicFetch from '../../lib/fetchers/periodicFetch'; import periodicFetch from '../../lib/fetchers/periodicFetch';
import { AnvilContext } from '../AnvilContext'; import { AnvilContext } from '../AnvilContext';
import Spinner from '../Spinner'; import Spinner from '../Spinner';
import { LARGE_MOBILE_BREAKPOINT } from '../../lib/consts/DEFAULT_THEME'; import { LARGE_MOBILE_BREAKPOINT } from '../../lib/consts/DEFAULT_THEME';
const useStyles = makeStyles((theme) => ({ const PREFIX = 'SharedStorage';
header: {
const classes = {
header: `${PREFIX}-header`,
root: `${PREFIX}-root`,
};
const StyledDiv = styled('div')(({ theme }) => ({
[`& .${classes.header}`]: {
paddingTop: '.1em', paddingTop: '.1em',
paddingRight: '.7em', paddingRight: '.7em',
}, },
root: {
[`& .${classes.root}`]: {
overflow: 'auto', overflow: 'auto',
height: '78vh', height: '78vh',
paddingLeft: '.3em', paddingLeft: '.3em',
@ -26,51 +34,56 @@ const useStyles = makeStyles((theme) => ({
})); }));
const SharedStorage = ({ anvil }: { anvil: AnvilListItem[] }): JSX.Element => { const SharedStorage = ({ anvil }: { anvil: AnvilListItem[] }): JSX.Element => {
const classes = useStyles();
const { uuid } = useContext(AnvilContext); const { uuid } = useContext(AnvilContext);
const { data, isLoading } = PeriodicFetch<AnvilSharedStorage>( const { data, isLoading } = periodicFetch<AnvilSharedStorage>(
`${process.env.NEXT_PUBLIC_API_URL}/get_shared_storage?anvil_uuid=${uuid}`, `${process.env.NEXT_PUBLIC_API_URL}/get_shared_storage?anvil_uuid=${uuid}`,
); );
return ( return (
<Panel> <Panel>
<HeaderText text="Shared Storage" /> <StyledDiv>
{!isLoading ? ( <HeaderText text="Shared Storage" />
<Box className={classes.root}> {!isLoading ? (
{data?.file_systems && <Box className={classes.root}>
data.file_systems.map( {data?.file_systems &&
(fs: AnvilFileSystem): JSX.Element => ( data.file_systems.map(
<InnerPanel key={fs.mount_point}> (fs: AnvilFileSystem): JSX.Element => (
<PanelHeader> <InnerPanel key={fs.mount_point}>
<Box display="flex" width="100%" className={classes.header}> <InnerPanelHeader>
<Box> <Box
<BodyText text={fs.mount_point} /> display="flex"
width="100%"
className={classes.header}
>
<Box>
<BodyText text={fs.mount_point} />
</Box>
</Box> </Box>
</Box> </InnerPanelHeader>
</PanelHeader> {fs?.hosts &&
{fs?.hosts && fs.hosts.map(
fs.hosts.map( (
( host: AnvilFileSystemHost,
host: AnvilFileSystemHost, index: number,
index: number, ): JSX.Element => (
): JSX.Element => ( <SharedStorageHost
<SharedStorageHost host={{
host={{ ...host,
...host, ...anvil[
...anvil[ anvil.findIndex((a) => a.anvil_uuid === uuid)
anvil.findIndex((a) => a.anvil_uuid === uuid) ].hosts[index],
].hosts[index], }}
}} key={fs.hosts[index].free}
key={fs.hosts[index].free} />
/> ),
), )}
)} </InnerPanel>
</InnerPanel> ),
), )}
)} </Box>
</Box> ) : (
) : ( <Spinner />
<Spinner /> )}
)} </StyledDiv>
</Panel> </Panel>
); );
}; };

@ -1,21 +1,31 @@
import { Box } from '@material-ui/core'; import { Box } from '@mui/material';
import { makeStyles } from '@material-ui/core/styles'; import { styled } from '@mui/material/styles';
import * as prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
import { AllocationBar } from '../Bars'; import { AllocationBar } from '../Bars';
import { BodyText } from '../Text'; import { BodyText } from '../Text';
import Decorator from '../Decorator'; import Decorator from '../Decorator';
const useStyles = makeStyles(() => ({ const PREFIX = 'SharedStorageHost';
fs: {
const classes = {
fs: `${PREFIX}-fs`,
bar: `${PREFIX}-bar`,
decoratorBox: `${PREFIX}-decoratorBox`,
};
const StyledDiv = styled('div')(() => ({
[`& .${classes.fs}`]: {
paddingLeft: '.7em', paddingLeft: '.7em',
paddingRight: '.7em', paddingRight: '.7em',
paddingTop: '1.2em', paddingTop: '1.2em',
}, },
bar: {
[`& .${classes.bar}`]: {
paddingLeft: '.7em', paddingLeft: '.7em',
paddingRight: '.7em', paddingRight: '.7em',
}, },
decoratorBox: {
[`& .${classes.decoratorBox}`]: {
paddingRight: '.3em', paddingRight: '.3em',
}, },
})); }));
@ -24,57 +34,54 @@ const SharedStorageHost = ({
host, host,
}: { }: {
host: AnvilFileSystemHost; host: AnvilFileSystemHost;
}): JSX.Element => { }): JSX.Element => (
const classes = useStyles(); <StyledDiv>
return ( <Box display="flex" width="100%" className={classes.fs}>
<> <Box flexGrow={1}>
<Box display="flex" width="100%" className={classes.fs}> <BodyText text={host.host_name || 'Not Available'} />
<Box flexGrow={1}>
<BodyText text={host.host_name || 'Not Available'} />
</Box>
<Box className={classes.decoratorBox}>
<Decorator colour={host.is_mounted ? 'ok' : 'error'} />
</Box>
<Box>
<BodyText text={host.is_mounted ? 'Mounted' : 'Not Mounted'} />
</Box>
</Box> </Box>
{host.is_mounted && ( <Box className={classes.decoratorBox}>
<> <Decorator colour={host.is_mounted ? 'ok' : 'error'} />
<Box display="flex" width="100%" className={classes.fs}> </Box>
<Box flexGrow={1}> <Box>
<BodyText <BodyText text={host.is_mounted ? 'Mounted' : 'Not Mounted'} />
text={`Used: ${prettyBytes.default(host.total - host.free, { </Box>
binary: true, </Box>
})}`} {host.is_mounted && (
/> <>
</Box> <Box display="flex" width="100%" className={classes.fs}>
<Box> <Box flexGrow={1}>
<BodyText <BodyText
text={`Free: ${prettyBytes.default(host.free, { text={`Used: ${prettyBytes.default(host.total - host.free, {
binary: true, binary: true,
})}`} })}`}
/> />
</Box>
</Box>
<Box display="flex" width="100%" className={classes.bar}>
<Box flexGrow={1}>
<AllocationBar
allocated={((host.total - host.free) / host.total) * 100}
/>
</Box>
</Box> </Box>
<Box display="flex" justifyContent="center" width="100%"> <Box>
<BodyText <BodyText
text={`Total Storage: ${prettyBytes.default(host.total, { text={`Free: ${prettyBytes.default(host.free, {
binary: true, binary: true,
})}`} })}`}
/> />
</Box> </Box>
</> </Box>
)} <Box display="flex" width="100%" className={classes.bar}>
</> <Box flexGrow={1}>
); <AllocationBar
}; allocated={((host.total - host.free) / host.total) * 100}
/>
</Box>
</Box>
<Box display="flex" justifyContent="center" width="100%">
<BodyText
text={`Total Storage: ${prettyBytes.default(host.total, {
binary: true,
})}`}
/>
</Box>
</>
)}
</StyledDiv>
);
export default SharedStorageHost; export default SharedStorageHost;

@ -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;

@ -1,54 +1,66 @@
import { useState } from 'react'; import { useState } from 'react';
import AppBar from '@material-ui/core/AppBar'; import { styled } from '@mui/material/styles';
import { makeStyles } from '@material-ui/core/styles'; import { AppBar, Box, Button } from '@mui/material';
import { Box, Button } from '@material-ui/core';
import { ICONS, ICON_SIZE } from '../lib/consts/ICONS'; import { ICONS, ICON_SIZE } from '../lib/consts/ICONS';
import { BORDER_RADIUS, RED } from '../lib/consts/DEFAULT_THEME'; import { BORDER_RADIUS, RED } from '../lib/consts/DEFAULT_THEME';
import AnvilDrawer from './AnvilDrawer'; import AnvilDrawer from './AnvilDrawer';
const useStyles = makeStyles((theme) => ({ const PREFIX = 'Header';
appBar: {
paddingTop: theme.spacing(0.5), const classes = {
paddingBottom: theme.spacing(0.5), input: `${PREFIX}-input`,
paddingLeft: theme.spacing(3), barElement: `${PREFIX}-barElement`,
paddingRight: theme.spacing(3), iconBox: `${PREFIX}-iconBox`,
borderBottom: 'solid 1px', searchBar: `${PREFIX}-searchBar`,
borderBottomColor: RED, icons: `${PREFIX}-icons`,
}, };
input: {
const StyledAppBar = styled(AppBar)(({ theme }) => ({
paddingTop: theme.spacing(0.5),
paddingBottom: theme.spacing(0.5),
paddingLeft: theme.spacing(3),
paddingRight: theme.spacing(3),
borderBottom: 'solid 1px',
borderBottomColor: RED,
position: 'static',
[`& .${classes.input}`]: {
height: '2.8em', height: '2.8em',
width: '30vw', width: '30vw',
backgroundColor: theme.palette.secondary.main, backgroundColor: theme.palette.secondary.main,
borderRadius: BORDER_RADIUS, borderRadius: BORDER_RADIUS,
}, },
barElement: {
[`& .${classes.barElement}`]: {
padding: 0, padding: 0,
}, },
iconBox: {
[`& .${classes.iconBox}`]: {
[theme.breakpoints.down('sm')]: { [theme.breakpoints.down('sm')]: {
display: 'none', display: 'none',
}, },
}, },
searchBar: {
[`& .${classes.searchBar}`]: {
[theme.breakpoints.down('sm')]: { [theme.breakpoints.down('sm')]: {
flexGrow: 1, flexGrow: 1,
paddingLeft: '15vw', paddingLeft: '15vw',
}, },
}, },
icons: {
[`& .${classes.icons}`]: {
paddingLeft: '.1em', paddingLeft: '.1em',
paddingRight: '.1em', paddingRight: '.1em',
}, },
})); }));
const Header = (): JSX.Element => { const Header = (): JSX.Element => {
const classes = useStyles();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const toggleDrawer = (): void => setOpen(!open); const toggleDrawer = (): void => setOpen(!open);
return ( return (
<AppBar position="static" className={classes.appBar}> <StyledAppBar>
<Box display="flex" justifyContent="space-between" flexDirection="row"> <Box display="flex" justifyContent="space-between" flexDirection="row">
<Box className={classes.barElement}> <Box className={classes.barElement}>
<Button onClick={toggleDrawer}> <Button onClick={toggleDrawer}>
@ -80,7 +92,7 @@ const Header = (): JSX.Element => {
</Box> </Box>
</Box> </Box>
<AnvilDrawer open={open} setOpen={setOpen} /> <AnvilDrawer open={open} setOpen={setOpen} />
</AppBar> </StyledAppBar>
); );
}; };

@ -1,6 +1,6 @@
import { Box, Switch } from '@material-ui/core'; import { Box, Switch } from '@mui/material';
import { makeStyles } from '@material-ui/core/styles'; import { styled } from '@mui/material/styles';
import { InnerPanel, PanelHeader } from '../Panels'; import { InnerPanel, InnerPanelHeader } from '../Panels';
import { ProgressBar } from '../Bars'; import { ProgressBar } from '../Bars';
import { BodyText } from '../Text'; import { BodyText } from '../Text';
import Decorator, { Colours } from '../Decorator'; import Decorator, { Colours } from '../Decorator';
@ -9,34 +9,47 @@ import HOST_STATUS from '../../lib/consts/NODES';
import putFetch from '../../lib/fetchers/putFetch'; import putFetch from '../../lib/fetchers/putFetch';
import { LARGE_MOBILE_BREAKPOINT } from '../../lib/consts/DEFAULT_THEME'; import { LARGE_MOBILE_BREAKPOINT } from '../../lib/consts/DEFAULT_THEME';
const useStyles = makeStyles((theme) => ({ const PREFIX = 'AnvilHost';
root: {
overflow: 'auto', const classes = {
height: '28vh', state: `${PREFIX}-state`,
paddingLeft: '.3em', bar: `${PREFIX}-bar`,
paddingRight: '.3em', header: `${PREFIX}-header`,
[theme.breakpoints.down(LARGE_MOBILE_BREAKPOINT)]: { label: `${PREFIX}-label`,
height: '100%', decoratorBox: `${PREFIX}-decoratorBox`,
overflow: 'hidden', };
},
const StyledBox = styled(Box)(({ theme }) => ({
overflow: 'auto',
height: '28vh',
paddingLeft: '.3em',
paddingRight: '.3em',
[theme.breakpoints.down(LARGE_MOBILE_BREAKPOINT)]: {
height: '100%',
overflow: 'hidden',
}, },
state: {
[`& .${classes.state}`]: {
paddingLeft: '.7em', paddingLeft: '.7em',
paddingRight: '.7em', paddingRight: '.7em',
paddingTop: '1em', paddingTop: '1em',
}, },
bar: {
[`& .${classes.bar}`]: {
paddingLeft: '.7em', paddingLeft: '.7em',
paddingRight: '.7em', paddingRight: '.7em',
}, },
header: {
[`& .${classes.header}`]: {
paddingTop: '.3em', paddingTop: '.3em',
paddingRight: '.7em', paddingRight: '.7em',
}, },
label: {
[`& .${classes.label}`]: {
paddingTop: '.3em', paddingTop: '.3em',
}, },
decoratorBox: {
[`& .${classes.decoratorBox}`]: {
paddingRight: '.3em', paddingRight: '.3em',
}, },
})); }));
@ -66,92 +79,99 @@ const AnvilHost = ({
}: { }: {
hosts: Array<AnvilStatusHost>; hosts: Array<AnvilStatusHost>;
}): JSX.Element => { }): JSX.Element => {
const classes = useStyles();
const stateRegex = /^[a-zA-Z]/; const stateRegex = /^[a-zA-Z]/;
const messageRegex = /^(message_[0-9]+)/; const messageRegex = /^(message_[0-9]+)/;
return ( return (
<Box className={classes.root}> <StyledBox>
{hosts && {hosts &&
hosts.map((host): JSX.Element => { hosts.map(
return ( (host): JSX.Element =>
<InnerPanel key={host.host_uuid}> // Temporary fix: avoid crash when encounter undefined host entry by returning a blank element.
<PanelHeader> // TODO: figure out why there are undefined host entries.
<Box display="flex" width="100%" className={classes.header}> host ? (
<InnerPanel key={host.host_uuid}>
<InnerPanelHeader>
<Box display="flex" width="100%" className={classes.header}>
<Box flexGrow={1}>
<BodyText text={host.host_name} />
</Box>
<Box className={classes.decoratorBox}>
<Decorator colour={selectDecorator(host.state)} />
</Box>
<Box>
<BodyText
text={
host?.state?.replace(stateRegex, (c) =>
c.toUpperCase(),
) || 'Not Available'
}
/>
</Box>
</Box>
</InnerPanelHeader>
<Box display="flex" className={classes.state}>
<Box className={classes.label}>
<BodyText text="Power: " />
</Box>
<Box flexGrow={1}> <Box flexGrow={1}>
<BodyText text={host.host_name} /> <Switch
checked={host.state === 'online'}
onChange={() =>
putFetch(
`${process.env.NEXT_PUBLIC_API_URL}/set_power`,
{
host_uuid: host.host_uuid,
is_on: !(host.state === 'online'),
},
)
}
/>
</Box> </Box>
<Box className={classes.decoratorBox}> <Box className={classes.label}>
<Decorator colour={selectDecorator(host.state)} /> <BodyText text="Member: " />
</Box> </Box>
<Box> <Box>
<BodyText <Switch
text={ checked={host.state === 'online'}
host?.state?.replace(stateRegex, (c) => disabled={!(host.state === 'online')}
c.toUpperCase(), onChange={() =>
) || 'Not Available' putFetch(
`${process.env.NEXT_PUBLIC_API_URL}/set_membership`,
{
host_uuid: host.host_uuid,
is_member: !(host.state === 'online'),
},
)
} }
/> />
</Box> </Box>
</Box> </Box>
</PanelHeader> {host.state !== 'online' && host.state !== 'offline' && (
<Box display="flex" className={classes.state}> <>
<Box className={classes.label}> <Box display="flex" width="100%" className={classes.state}>
<BodyText text="Power: " /> <Box>
</Box> <BodyText
<Box flexGrow={1}> text={selectStateMessage(
<Switch messageRegex,
checked={host.state === 'online'} host.state_message,
onChange={() => )}
putFetch(`${process.env.NEXT_PUBLIC_API_URL}/set_power`, { />
host_uuid: host.host_uuid, </Box>
is_on: !(host.state === 'online'),
})
}
/>
</Box>
<Box className={classes.label}>
<BodyText text="Member: " />
</Box>
<Box>
<Switch
checked={host.state === 'online'}
disabled={!(host.state === 'online')}
onChange={() =>
putFetch(
`${process.env.NEXT_PUBLIC_API_URL}/set_membership`,
{
host_uuid: host.host_uuid,
is_member: !(host.state === 'online'),
},
)
}
/>
</Box>
</Box>
{host.state !== 'online' && host.state !== 'offline' && (
<>
<Box display="flex" width="100%" className={classes.state}>
<Box>
<BodyText
text={selectStateMessage(
messageRegex,
host.state_message,
)}
/>
</Box> </Box>
</Box> <Box display="flex" width="100%" className={classes.bar}>
<Box display="flex" width="100%" className={classes.bar}> <Box flexGrow={1}>
<Box flexGrow={1}> <ProgressBar progressPercentage={host.state_percent} />
<ProgressBar progressPercentage={host.state_percent} /> </Box>
</Box> </Box>
</Box> </>
</> )}
)} </InnerPanel>
</InnerPanel> ) : (
); <></>
})} ),
</Box> )}
</StyledBox>
); );
}; };

@ -2,7 +2,7 @@ import { useContext } from 'react';
import { Panel } from '../Panels'; import { Panel } from '../Panels';
import { HeaderText } from '../Text'; import { HeaderText } from '../Text';
import AnvilHost from './AnvilHost'; import AnvilHost from './AnvilHost';
import PeriodicFetch from '../../lib/fetchers/periodicFetch'; import periodicFetch from '../../lib/fetchers/periodicFetch';
import { AnvilContext } from '../AnvilContext'; import { AnvilContext } from '../AnvilContext';
import Spinner from '../Spinner'; import Spinner from '../Spinner';
import hostsSanitizer from '../../lib/sanitizers/hostsSanitizer'; import hostsSanitizer from '../../lib/sanitizers/hostsSanitizer';
@ -10,7 +10,7 @@ import hostsSanitizer from '../../lib/sanitizers/hostsSanitizer';
const Hosts = ({ anvil }: { anvil: AnvilListItem[] }): JSX.Element => { const Hosts = ({ anvil }: { anvil: AnvilListItem[] }): JSX.Element => {
const { uuid } = useContext(AnvilContext); const { uuid } = useContext(AnvilContext);
const { data, isLoading } = PeriodicFetch<AnvilStatus>( const { data, isLoading } = periodicFetch<AnvilStatus>(
`${process.env.NEXT_PUBLIC_API_URL}/get_status?anvil_uuid=${uuid}`, `${process.env.NEXT_PUBLIC_API_URL}/get_status?anvil_uuid=${uuid}`,
); );

@ -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;

@ -1,16 +1,16 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { Box } from '@material-ui/core'; import { Box } from '@mui/material';
import * as prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
import { Panel } from './Panels'; import { Panel } from './Panels';
import { AllocationBar } from './Bars'; import { AllocationBar } from './Bars';
import { HeaderText, BodyText } from './Text'; import { HeaderText, BodyText } from './Text';
import PeriodicFetch from '../lib/fetchers/periodicFetch'; import periodicFetch from '../lib/fetchers/periodicFetch';
import { AnvilContext } from './AnvilContext'; import { AnvilContext } from './AnvilContext';
import Spinner from './Spinner'; import Spinner from './Spinner';
const Memory = (): JSX.Element => { const Memory = (): JSX.Element => {
const { uuid } = useContext(AnvilContext); const { uuid } = useContext(AnvilContext);
const { data, isLoading } = PeriodicFetch<AnvilMemory>( const { data, isLoading } = periodicFetch<AnvilMemory>(
`${process.env.NEXT_PUBLIC_API_URL}/get_memory?anvil_uuid=${uuid}`, `${process.env.NEXT_PUBLIC_API_URL}/get_memory?anvil_uuid=${uuid}`,
); );

@ -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;

@ -1,9 +1,9 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { Box, Divider } from '@material-ui/core'; import { Box, Divider } from '@mui/material';
import { makeStyles } from '@material-ui/core/styles'; import { styled } from '@mui/material/styles';
import { Panel } from '../Panels'; import { Panel } from '../Panels';
import { HeaderText, BodyText } from '../Text'; import { HeaderText, BodyText } from '../Text';
import PeriodicFetch from '../../lib/fetchers/periodicFetch'; import periodicFetch from '../../lib/fetchers/periodicFetch';
import { import {
DIVIDER, DIVIDER,
LARGE_MOBILE_BREAKPOINT, LARGE_MOBILE_BREAKPOINT,
@ -13,8 +13,18 @@ import { AnvilContext } from '../AnvilContext';
import Decorator, { Colours } from '../Decorator'; import Decorator, { Colours } from '../Decorator';
import Spinner from '../Spinner'; import Spinner from '../Spinner';
const useStyles = makeStyles((theme) => ({ const PREFIX = 'Network';
container: {
const classes = {
container: `${PREFIX}-container`,
root: `${PREFIX}-root`,
noPaddingLeft: `${PREFIX}-noPaddingLeft`,
divider: `${PREFIX}-divider`,
verticalDivider: `${PREFIX}-verticalDivider`,
};
const StyledDiv = styled('div')(({ theme }) => ({
[`& .${classes.container}`]: {
width: '100%', width: '100%',
overflow: 'auto', overflow: 'auto',
height: '32vh', height: '32vh',
@ -24,17 +34,21 @@ const useStyles = makeStyles((theme) => ({
overflow: 'hidden', overflow: 'hidden',
}, },
}, },
root: {
[`& .${classes.root}`]: {
paddingTop: '.7em', paddingTop: '.7em',
paddingBottom: '.7em', paddingBottom: '.7em',
}, },
noPaddingLeft: {
[`& .${classes.noPaddingLeft}`]: {
paddingLeft: 0, paddingLeft: 0,
}, },
divider: {
background: DIVIDER, [`& .${classes.divider}`]: {
backgroundColor: DIVIDER,
}, },
verticalDivider: {
[`& .${classes.verticalDivider}`]: {
height: '3.5em', height: '3.5em',
}, },
})); }));
@ -54,21 +68,20 @@ const selectDecorator = (state: string): Colours => {
const Network = (): JSX.Element => { const Network = (): JSX.Element => {
const { uuid } = useContext(AnvilContext); const { uuid } = useContext(AnvilContext);
const classes = useStyles();
const { data, isLoading } = PeriodicFetch<AnvilNetwork>( const { data, isLoading } = periodicFetch<AnvilNetwork>(
`${process.env.NEXT_PUBLIC_API_URL}/get_networks?anvil_uuid=${uuid}`, `${process.env.NEXT_PUBLIC_API_URL}/get_networks?anvil_uuid=${uuid}`,
); );
const processed = processNetworkData(data); const processed = processNetworkData(data);
return ( return (
<Panel> <Panel>
<HeaderText text="Network" /> <StyledDiv>
{!isLoading ? ( <HeaderText text="Network" />
<Box className={classes.container}> {!isLoading ? (
{data && <Box className={classes.container}>
processed.bonds.map((bond: ProcessedBond) => { {data &&
return ( processed.bonds.map((bond: ProcessedBond) => (
<> <>
<Box <Box
display="flex" display="flex"
@ -114,12 +127,12 @@ const Network = (): JSX.Element => {
</Box> </Box>
<Divider className={classes.divider} /> <Divider className={classes.divider} />
</> </>
); ))}
})} </Box>
</Box> ) : (
) : ( <Spinner />
<Spinner /> )}
)} </StyledDiv>
</Panel> </Panel>
); );
}; };

@ -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 { ReactNode } from 'react';
import { Box } from '@material-ui/core'; import { Box } from '@mui/material';
import { makeStyles } from '@material-ui/core/styles'; import { styled } from '@mui/material/styles';
import { BORDER_RADIUS, DIVIDER } from '../../lib/consts/DEFAULT_THEME'; 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 = { type Props = {
children: ReactNode; children: ReactNode;
}; };
const useStyles = makeStyles(() => ({ const InnerPanel = ({ children }: Props): JSX.Element => (
innerBody: { <StyledBox>{children}</StyledBox>
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>;
};
export default InnerPanel; 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,27 +1,32 @@
import { ReactNode } from 'react'; import { GlobalStyles, PaperProps, styled } from '@mui/material';
import { makeStyles } from '@material-ui/core/styles';
import { import {
BORDER_RADIUS, BORDER_RADIUS,
PANEL_BACKGROUND, PANEL_BACKGROUND,
TEXT, TEXT,
} from '../../lib/consts/DEFAULT_THEME'; } from '../../lib/consts/DEFAULT_THEME';
type Props = { const PREFIX = 'Panel';
children: ReactNode;
const classes = {
paper: `${PREFIX}-paper`,
square: `${PREFIX}-square`,
topSquare: `${PREFIX}-topSquare`,
bottomSquare: `${PREFIX}-bottomSquare`,
}; };
const useStyles = makeStyles(() => ({ const StyledDiv = styled('div')(() => ({
paper: { margin: '1em',
position: 'relative',
[`& .${classes.paper}`]: {
padding: '2.1em', padding: '2.1em',
backgroundColor: PANEL_BACKGROUND, backgroundColor: PANEL_BACKGROUND,
opacity: 0.8, opacity: 0.8,
zIndex: 999, zIndex: 999,
}, },
container: {
margin: '1em', [`& .${classes.square}`]: {
position: 'relative',
},
square: {
content: '""', content: '""',
position: 'absolute', position: 'absolute',
width: '2.1em', width: '2.1em',
@ -34,39 +39,45 @@ const useStyles = makeStyles(() => ({
padding: 0, padding: 0,
margin: 0, margin: 0,
}, },
topSquare: {
[`& .${classes.topSquare}`]: {
top: '-.3em', top: '-.3em',
left: '-.3em', left: '-.3em',
}, },
bottomSquare: {
[`& .${classes.bottomSquare}`]: {
bottom: '-.3em', bottom: '-.3em',
right: '-.3em', right: '-.3em',
}, },
'@global': {
'*::-webkit-scrollbar': {
width: '.6em',
},
'*::-webkit-scrollbar-track': {
backgroundColor: PANEL_BACKGROUND,
},
'*::-webkit-scrollbar-thumb': {
backgroundColor: TEXT,
outline: '1px solid transparent',
borderRadius: BORDER_RADIUS,
},
},
})); }));
const Panel = ({ children }: Props): JSX.Element => { type PanelProps = PaperProps;
const classes = useStyles();
return ( const styledScrollbars = (
<div className={classes.container}> <GlobalStyles
<div className={`${classes.square} ${classes.topSquare}`} /> styles={{
<div className={`${classes.square} ${classes.bottomSquare}`} /> '*::-webkit-scrollbar': {
<div className={classes.paper}>{children}</div> width: '.6em',
</div> },
); '*::-webkit-scrollbar-track': {
}; backgroundColor: PANEL_BACKGROUND,
},
'*::-webkit-scrollbar-thumb': {
backgroundColor: TEXT,
outline: '1px solid transparent',
borderRadius: BORDER_RADIUS,
},
}}
/>
);
const Panel = ({ children }: PanelProps): JSX.Element => (
<StyledDiv>
{styledScrollbars}
<div className={`${classes.square} ${classes.topSquare}`} />
<div className={`${classes.square} ${classes.bottomSquare}`} />
<div className={classes.paper}>{children}</div>
</StyledDiv>
);
export default Panel; export default Panel;

@ -1,40 +1,15 @@
import { ReactNode } from 'react'; import { Box, styled } from '@mui/material';
import { Box } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles'; const PanelHeader = styled(Box)({
import { BORDER_RADIUS, DIVIDER } from '../../lib/consts/DEFAULT_THEME'; alignItems: 'center',
display: 'flex',
type Props = { flexDirection: 'row',
children: ReactNode; marginBottom: '1em',
}; width: '100%',
'& > :first-child': { flexGrow: 1 },
const useStyles = makeStyles(() => ({ '& > :not(:first-child, :last-child)': {
innerHeader: { marginRight: '.3em',
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%',
}, },
})); });
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; export default PanelHeader;

@ -1,5 +1,6 @@
import PanelHeader from './PanelHeader';
import InnerPanel from './InnerPanel'; import InnerPanel from './InnerPanel';
import InnerPanelHeader from './InnerPanelHeader';
import Panel from './Panel'; import Panel from './Panel';
import PanelHeader from './PanelHeader';
export { Panel, PanelHeader, InnerPanel }; export { InnerPanel, InnerPanelHeader, Panel, PanelHeader };

@ -1,42 +1,58 @@
import * as prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
import { makeStyles, Box, Divider } from '@material-ui/core'; import { Box, Divider } from '@mui/material';
import InsertLinkIcon from '@material-ui/icons/InsertLink'; import { styled } from '@mui/material/styles';
import { InnerPanel, PanelHeader } from '../Panels'; import InsertLinkIcon from '@mui/icons-material/InsertLink';
import { InnerPanel, InnerPanelHeader } from '../Panels';
import { BodyText } from '../Text'; import { BodyText } from '../Text';
import Decorator, { Colours } from '../Decorator'; import Decorator, { Colours } from '../Decorator';
import { DIVIDER } from '../../lib/consts/DEFAULT_THEME'; import { DIVIDER } from '../../lib/consts/DEFAULT_THEME';
const useStyles = makeStyles((theme) => ({ const PREFIX = 'ResourceVolumes';
root: {
overflow: 'auto', const classes = {
height: '100%', connection: `${PREFIX}-connection`,
paddingLeft: '.3em', bar: `${PREFIX}-bar`,
[theme.breakpoints.down('md')]: { header: `${PREFIX}-header`,
overflow: 'hidden', label: `${PREFIX}-label`,
}, decoratorBox: `${PREFIX}-decoratorBox`,
divider: `${PREFIX}-divider`,
};
const StyledBox = styled(Box)(({ theme }) => ({
overflow: 'auto',
height: '100%',
paddingLeft: '.3em',
[theme.breakpoints.down('md')]: {
overflow: 'hidden',
}, },
connection: {
[`& .${classes.connection}`]: {
paddingLeft: '.7em', paddingLeft: '.7em',
paddingRight: '.7em', paddingRight: '.7em',
paddingTop: '1em', paddingTop: '1em',
paddingBottom: '.7em', paddingBottom: '.7em',
}, },
bar: {
[`& .${classes.bar}`]: {
paddingLeft: '.7em', paddingLeft: '.7em',
paddingRight: '.7em', paddingRight: '.7em',
}, },
header: {
[`& .${classes.header}`]: {
paddingTop: '.3em', paddingTop: '.3em',
paddingRight: '.7em', paddingRight: '.7em',
}, },
label: {
[`& .${classes.label}`]: {
paddingTop: '.3em', paddingTop: '.3em',
}, },
decoratorBox: {
[`& .${classes.decoratorBox}`]: {
paddingRight: '.3em', paddingRight: '.3em',
}, },
divider: {
background: DIVIDER, [`& .${classes.divider}`]: {
backgroundColor: DIVIDER,
}, },
})); }));
@ -55,69 +71,59 @@ const ResourceVolumes = ({
resource, resource,
}: { }: {
resource: AnvilReplicatedStorage; resource: AnvilReplicatedStorage;
}): JSX.Element => { }): JSX.Element => (
const classes = useStyles(); <StyledBox>
{resource &&
return ( resource.volumes.map((volume) => (
<Box className={classes.root}> <InnerPanel key={volume.drbd_device_minor}>
{resource && <InnerPanelHeader>
resource.volumes.map((volume) => { <Box display="flex" width="100%" className={classes.header}>
return ( <Box flexGrow={1}>
<InnerPanel key={volume.drbd_device_minor}> <BodyText text={`Volume: ${volume.number}`} />
<PanelHeader> </Box>
<Box display="flex" width="100%" className={classes.header}> <Box>
<Box flexGrow={1}> <BodyText
<BodyText text={`Volume: ${volume.number}`} /> text={`Size: ${prettyBytes.default(volume.size, {
binary: true,
})}`}
/>
</Box>
</Box>
</InnerPanelHeader>
{volume.connections.map(
(connection, index): JSX.Element => (
<>
<Box
key={connection.fencing}
display="flex"
width="100%"
className={classes.connection}
>
<Box className={classes.decoratorBox}>
<Decorator
colour={selectDecorator(connection.connection_state)}
/>
</Box> </Box>
<Box> <Box>
<BodyText <Box display="flex" width="100%">
text={`Size: ${prettyBytes.default(volume.size, { <BodyText text={connection.targets[0].target_name} />
binary: true, <InsertLinkIcon style={{ color: DIVIDER }} />
})}`} <BodyText text={connection.targets[1].target_name} />
/> </Box>
<Box display="flex" justifyContent="center" width="100%">
<BodyText text={connection.connection_state} />
</Box>
</Box> </Box>
</Box> </Box>
</PanelHeader> {volume.connections.length - 1 !== index ? (
{volume.connections.map((connection, index): JSX.Element => { <Divider className={classes.divider} />
return ( ) : null}
<> </>
<Box ),
key={connection.fencing} )}
display="flex" </InnerPanel>
width="100%" ))}
className={classes.connection} </StyledBox>
> );
<Box className={classes.decoratorBox}>
<Decorator
colour={selectDecorator(connection.connection_state)}
/>
</Box>
<Box>
<Box display="flex" width="100%">
<BodyText text={connection.targets[0].target_name} />
<InsertLinkIcon style={{ color: DIVIDER }} />
<BodyText text={connection.targets[1].target_name} />
</Box>
<Box
display="flex"
justifyContent="center"
width="100%"
>
<BodyText text={connection.connection_state} />
</Box>
</Box>
</Box>
{volume.connections.length - 1 !== index ? (
<Divider className={classes.divider} />
) : null}
</>
);
})}
</InnerPanel>
);
})}
</Box>
);
};
export default ResourceVolumes; export default ResourceVolumes;

@ -6,13 +6,11 @@ const Resource = ({
resource, resource,
}: { }: {
resource: AnvilReplicatedStorage; resource: AnvilReplicatedStorage;
}): JSX.Element => { }): JSX.Element => (
return ( <Panel>
<Panel> <HeaderText text={`Resource: ${resource.resource_name}`} />
<HeaderText text={`Resource: ${resource.resource_name}`} /> <ResourceVolumes resource={resource} />
<ResourceVolumes resource={resource} /> </Panel>
</Panel> );
);
};
export default Resource; export default Resource;

@ -1,43 +1,68 @@
import { useState, useContext, useRef } from 'react'; import { useState, useContext, useRef } from 'react';
import { import {
List,
ListItem,
Divider,
Box, Box,
IconButton,
Button, Button,
Checkbox, Checkbox,
Divider,
List,
ListItem,
Menu, Menu,
MenuItem, MenuItem,
styled,
Typography, Typography,
} from '@material-ui/core'; } from '@mui/material';
import EditIcon from '@material-ui/icons/Edit';
import MoreVertIcon from '@material-ui/icons/MoreVert';
import CheckIcon from '@material-ui/icons/Check';
import { makeStyles } from '@material-ui/core/styles';
import { Panel } from './Panels';
import PeriodicFetch from '../lib/fetchers/periodicFetch';
import { HeaderText, BodyText } from './Text';
import { import {
HOVER, Add as AddIcon,
DIVIDER, Check as CheckIcon,
TEXT, Edit as EditIcon,
MoreVert as MoreVertIcon,
} from '@mui/icons-material';
import {
BLACK,
BLUE, BLUE,
RED, DIVIDER,
GREY, GREY,
BLACK, HOVER,
LARGE_MOBILE_BREAKPOINT, LARGE_MOBILE_BREAKPOINT,
RED,
TEXT,
} from '../lib/consts/DEFAULT_THEME'; } from '../lib/consts/DEFAULT_THEME';
import { AnvilContext } from './AnvilContext';
import serverState from '../lib/consts/SERVERS'; import serverState from '../lib/consts/SERVERS';
import { AnvilContext } from './AnvilContext';
import Decorator, { Colours } from './Decorator'; import Decorator, { Colours } from './Decorator';
import IconButton from './IconButton';
import { Panel, PanelHeader } from './Panels';
import Spinner from './Spinner'; import Spinner from './Spinner';
import hostsSanitizer from '../lib/sanitizers/hostsSanitizer'; import { BodyText, HeaderText } from './Text';
import hostsSanitizer from '../lib/sanitizers/hostsSanitizer';
import periodicFetch from '../lib/fetchers/periodicFetch';
import putFetch from '../lib/fetchers/putFetch'; import putFetch from '../lib/fetchers/putFetch';
const useStyles = makeStyles((theme) => ({ const PREFIX = 'Servers';
root: {
const classes = {
root: `${PREFIX}-root`,
divider: `${PREFIX}-divider`,
verticalDivider: `${PREFIX}-verticalDivider`,
button: `${PREFIX}-button`,
headerPadding: `${PREFIX}-headerPadding`,
hostsBox: `${PREFIX}-hostsBox`,
hostBox: `${PREFIX}-hostBox`,
checkbox: `${PREFIX}-checkbox`,
serverActionButton: `${PREFIX}-serverActionButton`,
editButtonBox: `${PREFIX}-editButtonBox`,
dropdown: `${PREFIX}-dropdown`,
power: `${PREFIX}-power`,
on: `${PREFIX}-on`,
off: `${PREFIX}-off`,
all: `${PREFIX}-all`,
};
const StyledDiv = styled('div')(({ theme }) => ({
[`& .${classes.root}`]: {
width: '100%', width: '100%',
overflow: 'auto', overflow: 'auto',
height: '78vh', height: '78vh',
@ -47,63 +72,63 @@ const useStyles = makeStyles((theme) => ({
overflow: 'hidden', overflow: 'hidden',
}, },
}, },
divider: {
background: DIVIDER, [`& .${classes.divider}`]: {
backgroundColor: DIVIDER,
}, },
verticalDivider: {
[`& .${classes.verticalDivider}`]: {
height: '75%', height: '75%',
paddingTop: '1em', paddingTop: '1em',
}, },
button: {
[`& .${classes.button}`]: {
'&:hover': { '&:hover': {
backgroundColor: HOVER, backgroundColor: HOVER,
}, },
paddingLeft: 0, paddingLeft: 0,
}, },
headerPadding: {
[`& .${classes.headerPadding}`]: {
paddingLeft: '.3em', paddingLeft: '.3em',
}, },
hostsBox: {
[`& .${classes.hostsBox}`]: {
padding: '1em', padding: '1em',
paddingRight: 0, paddingRight: 0,
}, },
hostBox: {
[`& .${classes.hostBox}`]: {
paddingTop: 0, paddingTop: 0,
}, },
checkbox: {
[`& .${classes.checkbox}`]: {
paddingTop: '.8em', paddingTop: '.8em',
}, },
menuItem: {
backgroundColor: GREY, [`& .${classes.serverActionButton}`]: {
paddingRight: '3em', backgroundColor: TEXT,
'&:hover': { color: BLACK,
backgroundColor: GREY, textTransform: 'none',
},
},
editButton: {
borderRadius: 8,
backgroundColor: GREY,
'&:hover': { '&:hover': {
backgroundColor: GREY, backgroundColor: GREY,
}, },
}, },
editButtonBox: {
[`& .${classes.editButtonBox}`]: {
paddingTop: '.3em', paddingTop: '.3em',
}, },
dropdown: {
[`& .${classes.dropdown}`]: {
paddingTop: '.8em', paddingTop: '.8em',
paddingBottom: '.8em', paddingBottom: '.8em',
}, },
power: {
[`& .${classes.power}`]: {
color: BLACK, color: BLACK,
}, },
on: {
color: BLUE, [`& .${classes.all}`]: {
},
off: {
color: RED,
},
all: {
paddingTop: '.5em', paddingTop: '.5em',
paddingLeft: '.3em', paddingLeft: '.3em',
}, },
@ -122,6 +147,24 @@ const selectDecorator = (state: string): Colours => {
} }
}; };
const ServerActionButtonMenuItem = styled(MenuItem)({
backgroundColor: GREY,
paddingRight: '3em',
'&:hover': {
backgroundColor: GREY,
},
});
const ServerActionButtonMenuItemLabel = styled(Typography)({
[`&.${classes.on}`]: {
color: BLUE,
},
[`&.${classes.off}`]: {
color: RED,
},
});
type ButtonLabels = 'on' | 'off'; type ButtonLabels = 'on' | 'off';
const Servers = ({ anvil }: { anvil: AnvilListItem[] }): JSX.Element => { const Servers = ({ anvil }: { anvil: AnvilListItem[] }): JSX.Element => {
@ -129,11 +172,12 @@ const Servers = ({ anvil }: { anvil: AnvilListItem[] }): JSX.Element => {
const [showCheckbox, setShowCheckbox] = useState<boolean>(false); const [showCheckbox, setShowCheckbox] = useState<boolean>(false);
const [allSelected, setAllSelected] = useState<boolean>(false); const [allSelected, setAllSelected] = useState<boolean>(false);
const [selected, setSelected] = useState<string[]>([]); const [selected, setSelected] = useState<string[]>([]);
const buttonLabels = useRef<ButtonLabels[]>([]);
const { uuid } = useContext(AnvilContext); const { uuid } = useContext(AnvilContext);
const classes = useStyles();
const { data, isLoading } = PeriodicFetch<AnvilServers>( const buttonLabels = useRef<ButtonLabels[]>([]);
const { data, isLoading } = periodicFetch<AnvilServers>(
`${process.env.NEXT_PUBLIC_API_URL}/get_servers?anvil_uuid=${uuid}`, `${process.env.NEXT_PUBLIC_API_URL}/get_servers?anvil_uuid=${uuid}`,
); );
@ -187,93 +231,85 @@ const Servers = ({ anvil }: { anvil: AnvilListItem[] }): JSX.Element => {
return ( return (
<Panel> <Panel>
<Box className={classes.headerPadding} display="flex"> <StyledDiv>
<Box flexGrow={1}> <PanelHeader className={classes.headerPadding} sx={{ marginBottom: 0 }}>
<HeaderText text="Servers" /> <HeaderText text="Servers" />
</Box> <IconButton>
<Box className={classes.editButtonBox}> <AddIcon />
<IconButton
className={classes.editButton}
style={{ color: BLACK }}
onClick={() => setShowCheckbox(!showCheckbox)}
>
{showCheckbox ? <CheckIcon /> : <EditIcon />}
</IconButton> </IconButton>
</Box> <IconButton onClick={() => setShowCheckbox(!showCheckbox)}>
</Box> {showCheckbox ? <CheckIcon sx={{ color: BLUE }} /> : <EditIcon />}
{showCheckbox && ( </IconButton>
<> </PanelHeader>
<Box className={classes.headerPadding} display="flex"> {showCheckbox && (
<Box flexGrow={1} className={classes.dropdown}> <>
<Button <Box className={classes.headerPadding} display="flex">
variant="contained" <Box flexGrow={1} className={classes.dropdown}>
startIcon={<MoreVertIcon />} <Button
onClick={handleClick} variant="contained"
style={{ textTransform: 'none' }} startIcon={<MoreVertIcon />}
> onClick={handleClick}
<Typography className={classes.power} variant="subtitle1"> className={classes.serverActionButton}
Power >
</Typography> <Typography className={classes.power} variant="subtitle1">
</Button> Power
<Menu </Typography>
anchorEl={anchorEl} </Button>
keepMounted <Menu
open={Boolean(anchorEl)} anchorEl={anchorEl}
onClose={() => setAnchorEl(null)} keepMounted
> open={Boolean(anchorEl)}
{buttonLabels.current.map((label: ButtonLabels) => { onClose={() => setAnchorEl(null)}
return ( >
<MenuItem {buttonLabels.current.map((label: ButtonLabels) => (
<ServerActionButtonMenuItem
onClick={() => handlePower(label)} onClick={() => handlePower(label)}
className={classes.menuItem}
key={label} key={label}
> >
<Typography <ServerActionButtonMenuItemLabel
className={classes[label]} className={classes[label]}
variant="subtitle1" variant="subtitle1"
> >
{label.replace(/^[a-z]/, (c) => c.toUpperCase())} {label.replace(/^[a-z]/, (c) => c.toUpperCase())}
</Typography> </ServerActionButtonMenuItemLabel>
</MenuItem> </ServerActionButtonMenuItem>
); ))}
})} </Menu>
</Menu> </Box>
</Box>
</Box>
<Box display="flex">
<Box>
<Checkbox
style={{ color: TEXT }}
color="secondary"
checked={allSelected}
onChange={() => {
if (!allSelected) {
setButtons(data.servers);
setSelected(
data.servers.map(
(server: AnvilServer) => server.server_uuid,
),
);
} else {
setButtons([]);
setSelected([]);
}
setAllSelected(!allSelected);
}}
/>
</Box> </Box>
<Box className={classes.all}> <Box display="flex">
<BodyText text="All" /> <Box>
<Checkbox
style={{ color: TEXT }}
color="secondary"
checked={allSelected}
onChange={() => {
if (!allSelected) {
setButtons(data.servers);
setSelected(
data.servers.map(
(server: AnvilServer) => server.server_uuid,
),
);
} else {
setButtons([]);
setSelected([]);
}
setAllSelected(!allSelected);
}}
/>
</Box>
<Box className={classes.all}>
<BodyText text="All" />
</Box>
</Box> </Box>
</Box> </>
</> )}
)} {!isLoading ? (
{!isLoading ? ( <Box className={classes.root}>
<Box className={classes.root}> <List component="nav">
<List component="nav"> {data?.servers.map((server: AnvilServer) => (
{data?.servers.map((server: AnvilServer) => {
return (
<> <>
<ListItem <ListItem
button button
@ -345,13 +381,13 @@ const Servers = ({ anvil }: { anvil: AnvilListItem[] }): JSX.Element => {
</ListItem> </ListItem>
<Divider className={classes.divider} /> <Divider className={classes.divider} />
</> </>
); ))}
})} </List>
</List> </Box>
</Box> ) : (
) : ( <Spinner />
<Spinner /> )}
)} </StyledDiv>
</Panel> </Panel>
); );
}; };

@ -1,21 +1,29 @@
import { useContext } from 'react'; import { useContext } from 'react';
import { Box } from '@material-ui/core'; import { Box } from '@mui/material';
import { makeStyles } from '@material-ui/core/styles'; import { styled } from '@mui/material/styles';
import { BodyText, HeaderText } from '../Text'; import { BodyText, HeaderText } from '../Text';
import { Panel, InnerPanel, PanelHeader } from '../Panels'; import { Panel, InnerPanel, InnerPanelHeader } from '../Panels';
import SharedStorageHost from './SharedStorageHost'; import SharedStorageHost from './SharedStorageHost';
import PeriodicFetch from '../../lib/fetchers/periodicFetch'; import periodicFetch from '../../lib/fetchers/periodicFetch';
import { AnvilContext } from '../AnvilContext'; import { AnvilContext } from '../AnvilContext';
import Spinner from '../Spinner'; import Spinner from '../Spinner';
import { LARGE_MOBILE_BREAKPOINT } from '../../lib/consts/DEFAULT_THEME'; import { LARGE_MOBILE_BREAKPOINT } from '../../lib/consts/DEFAULT_THEME';
const useStyles = makeStyles((theme) => ({ const PREFIX = 'SharedStorage';
header: {
const classes = {
header: `${PREFIX}-header`,
root: `${PREFIX}-root`,
};
const StyledDiv = styled('div')(({ theme }) => ({
[`& .${classes.header}`]: {
paddingTop: '.1em', paddingTop: '.1em',
paddingRight: '.7em', paddingRight: '.7em',
}, },
root: {
[`& .${classes.root}`]: {
overflow: 'auto', overflow: 'auto',
height: '78vh', height: '78vh',
paddingLeft: '.3em', paddingLeft: '.3em',
@ -27,38 +35,43 @@ const useStyles = makeStyles((theme) => ({
})); }));
const SharedStorage = (): JSX.Element => { const SharedStorage = (): JSX.Element => {
const classes = useStyles();
const { uuid } = useContext(AnvilContext); const { uuid } = useContext(AnvilContext);
const { data, isLoading } = PeriodicFetch<AnvilSharedStorage>( const { data, isLoading } = periodicFetch<AnvilSharedStorage>(
`${process.env.NEXT_PUBLIC_API_URL}/get_shared_storage?anvil_uuid=${uuid}`, `${process.env.NEXT_PUBLIC_API_URL}/get_shared_storage?anvil_uuid=${uuid}`,
); );
return ( return (
<Panel> <Panel>
<HeaderText text="Shared Storage" /> <StyledDiv>
{!isLoading ? ( <HeaderText text="Shared Storage" />
<Box className={classes.root}> {!isLoading ? (
{data?.storage_groups && <Box className={classes.root}>
data.storage_groups.map( {data?.storage_groups &&
(storageGroup: AnvilSharedStorageGroup): JSX.Element => ( data.storage_groups.map(
<InnerPanel key={storageGroup.storage_group_uuid}> (storageGroup: AnvilSharedStorageGroup): JSX.Element => (
<PanelHeader> <InnerPanel key={storageGroup.storage_group_uuid}>
<Box display="flex" width="100%" className={classes.header}> <InnerPanelHeader>
<Box> <Box
<BodyText text={storageGroup.storage_group_name} /> display="flex"
width="100%"
className={classes.header}
>
<Box>
<BodyText text={storageGroup.storage_group_name} />
</Box>
</Box> </Box>
</Box> </InnerPanelHeader>
</PanelHeader> <SharedStorageHost
<SharedStorageHost group={storageGroup}
group={storageGroup} key={storageGroup.storage_group_uuid}
key={storageGroup.storage_group_uuid} />
/> </InnerPanel>
</InnerPanel> ),
), )}
)} </Box>
</Box> ) : (
) : ( <Spinner />
<Spinner /> )}
)} </StyledDiv>
</Panel> </Panel>
); );
}; };

@ -1,20 +1,30 @@
import { Box } from '@material-ui/core'; import { Box } from '@mui/material';
import { makeStyles } from '@material-ui/core/styles'; import { styled } from '@mui/material/styles';
import * as prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
import { AllocationBar } from '../Bars'; import { AllocationBar } from '../Bars';
import { BodyText } from '../Text'; import { BodyText } from '../Text';
const useStyles = makeStyles(() => ({ const PREFIX = 'SharedStorageHost';
fs: {
const classes = {
fs: `${PREFIX}-fs`,
bar: `${PREFIX}-bar`,
decoratorBox: `${PREFIX}-decoratorBox`,
};
const StyledDiv = styled('div')(() => ({
[`& .${classes.fs}`]: {
paddingLeft: '.7em', paddingLeft: '.7em',
paddingRight: '.7em', paddingRight: '.7em',
paddingTop: '1.2em', paddingTop: '1.2em',
}, },
bar: {
[`& .${classes.bar}`]: {
paddingLeft: '.7em', paddingLeft: '.7em',
paddingRight: '.7em', paddingRight: '.7em',
}, },
decoratorBox: {
[`& .${classes.decoratorBox}`]: {
paddingRight: '.3em', paddingRight: '.3em',
}, },
})); }));
@ -23,52 +33,46 @@ const SharedStorageHost = ({
group, group,
}: { }: {
group: AnvilSharedStorageGroup; group: AnvilSharedStorageGroup;
}): JSX.Element => { }): JSX.Element => (
const classes = useStyles(); <StyledDiv>
return ( <Box display="flex" width="100%" className={classes.fs}>
<> <Box flexGrow={1}>
<Box display="flex" width="100%" className={classes.fs}>
<Box flexGrow={1}>
<BodyText
text={`Used: ${prettyBytes.default(
group.storage_group_total - group.storage_group_free,
{
binary: true,
},
)}`}
/>
</Box>
<Box>
<BodyText
text={`Free: ${prettyBytes.default(group.storage_group_free, {
binary: true,
})}`}
/>
</Box>
</Box>
<Box display="flex" width="100%" className={classes.bar}>
<Box flexGrow={1}>
<AllocationBar
allocated={
((group.storage_group_total - group.storage_group_free) /
group.storage_group_total) *
100
}
/>
</Box>
</Box>
<Box display="flex" justifyContent="center" width="100%">
<BodyText <BodyText
text={`Total Storage: ${prettyBytes.default( text={`Used: ${prettyBytes.default(
group.storage_group_total, group.storage_group_total - group.storage_group_free,
{ {
binary: true, binary: true,
}, },
)}`} )}`}
/> />
</Box> </Box>
</> <Box>
); <BodyText
}; text={`Free: ${prettyBytes.default(group.storage_group_free, {
binary: true,
})}`}
/>
</Box>
</Box>
<Box display="flex" width="100%" className={classes.bar}>
<Box flexGrow={1}>
<AllocationBar
allocated={
((group.storage_group_total - group.storage_group_free) /
group.storage_group_total) *
100
}
/>
</Box>
</Box>
<Box display="flex" justifyContent="center" width="100%">
<BodyText
text={`Total Storage: ${prettyBytes.default(group.storage_group_total, {
binary: true,
})}`}
/>
</Box>
</StyledDiv>
);
export default SharedStorageHost; export default SharedStorageHost;

@ -1,29 +1,28 @@
import { makeStyles } from '@material-ui/core/styles'; import { styled } from '@mui/material/styles';
import CircularProgress from '@material-ui/core/CircularProgress'; import { CircularProgress } from '@mui/material';
import { TEXT } from '../lib/consts/DEFAULT_THEME'; import { TEXT } from '../lib/consts/DEFAULT_THEME';
const useStyles = makeStyles(() => ({ const PREFIX = 'Spinner';
root: {
display: 'flex', const classes = {
alignItems: 'center', spinner: `${PREFIX}-spinner`,
justifyContent: 'center', };
marginTop: '3em',
}, const StyledDiv = styled('div')(() => ({
spinner: { display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: '3em',
[`& .${classes.spinner}`]: {
color: TEXT, color: TEXT,
variant: 'indeterminate',
size: '50em',
}, },
})); }));
const Spinner = (): JSX.Element => { const Spinner = (): JSX.Element => (
const classes = useStyles(); <StyledDiv>
<CircularProgress variant="indeterminate" className={classes.spinner} />
return ( </StyledDiv>
<div className={classes.root}> );
<CircularProgress className={classes.spinner} />
</div>
);
};
export default Spinner; export default Spinner;

@ -1,12 +1,12 @@
import { Grid } from '@material-ui/core'; import { Grid } from '@mui/material';
import * as prettyBytes from 'pretty-bytes'; import * as prettyBytes from 'pretty-bytes';
import { Panel } from './Panels'; import { Panel } from './Panels';
import { AllocationBar } from './Bars'; import { AllocationBar } from './Bars';
import { HeaderText, BodyText } from './Text'; import { HeaderText, BodyText } from './Text';
import PeriodicFetch from '../lib/fetchers/periodicFetch'; import periodicFetch from '../lib/fetchers/periodicFetch';
const Storage = ({ uuid }: { uuid: string }): JSX.Element => { const Storage = ({ uuid }: { uuid: string }): JSX.Element => {
const { data, isLoading } = PeriodicFetch<AnvilMemory>( const { data, isLoading } = periodicFetch<AnvilMemory>(
`${process.env.NEXT_PUBLIC_API_URL}/get_memory?anvil_uuid=${uuid}`, `${process.env.NEXT_PUBLIC_API_URL}/get_memory?anvil_uuid=${uuid}`,
); );
@ -14,7 +14,7 @@ const Storage = ({ uuid }: { uuid: string }): JSX.Element => {
return ( return (
<Panel> <Panel>
<Grid container alignItems="center" justify="space-around"> <Grid container alignItems="center" justifyContent="space-around">
<Grid item xs={12}> <Grid item xs={12}>
<HeaderText text="Storage Resync" /> <HeaderText text="Storage Resync" />
</Grid> </Grid>

@ -1,35 +1,77 @@
import { Typography } from '@material-ui/core'; import { styled, Typography, TypographyProps } from '@mui/material';
import { makeStyles } from '@material-ui/core/styles';
import { TEXT, UNSELECTED } from '../../lib/consts/DEFAULT_THEME';
interface TextProps { import { BLACK, TEXT, UNSELECTED } from '../../lib/consts/DEFAULT_THEME';
text: string;
selected?: boolean; const PREFIX = 'BodyText';
}
const classes = {
inverted: `${PREFIX}-inverted`,
selected: `${PREFIX}-selected`,
unselected: `${PREFIX}-unselected`,
};
const StyledTypography = styled(Typography)(() => ({
[`&.${classes.inverted}`]: {
color: BLACK,
},
const useStyles = makeStyles(() => ({ [`&.${classes.selected}`]: {
selected: {
color: TEXT, color: TEXT,
}, },
unselected: {
[`&.${classes.unselected}`]: {
color: UNSELECTED, color: UNSELECTED,
}, },
})); }));
const BodyText = ({ text, selected }: TextProps): JSX.Element => { type BodyTextProps = TypographyProps & {
const classes = useStyles(); inverted?: boolean;
selected?: boolean;
text: string;
};
const BodyText = ({
inverted,
selected,
sx,
text,
}: BodyTextProps): JSX.Element => {
const buildBodyTextClasses = ({
isInvert,
isSelect,
}: {
isInvert?: boolean;
isSelect?: boolean;
}) => {
let bodyTextClasses = '';
if (isInvert) {
bodyTextClasses += classes.inverted;
} else if (isSelect) {
bodyTextClasses += classes.selected;
} else {
bodyTextClasses += classes.unselected;
}
return bodyTextClasses;
};
return ( return (
<Typography <StyledTypography
{...{ sx }}
className={buildBodyTextClasses({
isInvert: inverted,
isSelect: selected,
})}
variant="subtitle1" variant="subtitle1"
className={selected ? classes.selected : classes.unselected}
> >
{text} {text}
</Typography> </StyledTypography>
); );
}; };
BodyText.defaultProps = { BodyText.defaultProps = {
inverted: false,
selected: true, selected: true,
}; };

@ -1,15 +1,13 @@
import { Typography } from '@material-ui/core'; import Typography from '@mui/material/Typography';
import { withStyles } from '@material-ui/core/styles'; import { styled } from '@mui/material/styles';
import { TEXT } from '../../lib/consts/DEFAULT_THEME'; import { TEXT } from '../../lib/consts/DEFAULT_THEME';
const WhiteTypography = withStyles({ const WhiteTypography = styled(Typography)({
root: { color: TEXT,
color: TEXT, });
},
})(Typography);
const HeaderText = ({ text }: { text: string }): JSX.Element => { const HeaderText = ({ text }: { text: string }): JSX.Element => (
return <WhiteTypography variant="h4">{text}</WhiteTypography>; <WhiteTypography variant="h4">{text}</WhiteTypography>
}; );
export default HeaderText; export default HeaderText;

@ -1,7 +1,6 @@
import IS_DEV_ENV from './IS_DEV_ENV'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL?.replace(
'/cgi-bin',
const API_BASE_URL = IS_DEV_ENV '/api',
? process.env.DEVELOPMENT_API_BASE_URL );
: process.env.PRODUCTION_API_BASE_URL;
export default API_BASE_URL; export default API_BASE_URL;

@ -2,7 +2,7 @@ export const ICONS = [
{ {
text: 'Files', text: 'Files',
image: '/pngs/files_on.png', image: '/pngs/files_on.png',
uri: '/striker?files=true', uri: '/../file-manager',
}, },
{ {
text: 'Tasks', text: 'Tasks',

@ -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> => { const fetchJSON = <T>(...args: [RequestInfo, RequestInit?]): Promise<T> =>
return fetch(...args).then((response: Response) => response.json()); fetch(...args).then((response: Response) => response.json());
};
export default fetchJSON; export default fetchJSON;

@ -1,13 +1,20 @@
import useSWR from 'swr'; import useSWR, { SWRConfiguration } from 'swr';
import fetcher from './fetchJSON'; import fetcher from './fetchJSON';
const PeriodicFetch = <T>( const periodicFetch = <T>(
url: string, url: string,
refreshInterval = 5000, { refreshInterval = 5000, onSuccess }: SWRConfiguration = {},
): GetResponses => { ): GetResponses => {
// The purpose of react-hooks/rules-of-hooks is to ensure that react hooks
// are called in order (i.e., not potentially skipped due to conditionals).
// We can safely disable this rule as this function is simply a wrapper.
// eslint-disable-next-line react-hooks/rules-of-hooks
const { data, error } = useSWR<T>(url, fetcher, { const { data, error } = useSWR<T>(url, fetcher, {
refreshInterval, refreshInterval,
onSuccess,
}); });
return { return {
data, data,
isLoading: !error && !data, isLoading: !error && !data,
@ -15,4 +22,4 @@ const PeriodicFetch = <T>(
}; };
}; };
export default PeriodicFetch; export default periodicFetch;

@ -1,12 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
const putFetch = <T>(uri: string, data: T): Promise<any> => { const putFetch = <T>(uri: string, data: T): Promise<any> =>
return fetch(uri, { fetch(uri, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
};
export default putFetch; export default putFetch;

@ -1,7 +1,4 @@
const hostsSanitizer = ( const hostsSanitizer = (data: Array<AnvilStatusHost>): Array<AnvilStatusHost> =>
data: Array<AnvilStatusHost>, data?.filter((host) => host.host_uuid);
): Array<AnvilStatusHost> => {
return data?.filter((host) => host.host_uuid);
};
export default hostsSanitizer; 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…
Cancel
Save