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";
}
# 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)
{
$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 \
pxe scancore-agents scripts share striker-ui \
tools units
striker-ui-api tools units
anvilconfdir = ${sysconfdir}/anvil

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

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

@ -74,7 +74,7 @@
<!-- end files_button_off -->
<!-- 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 -->
<!-- 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/warnings",
"plugin:jsx-a11y/recommended",
"plugin:prettier/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"prettier",
@ -23,7 +22,6 @@
"@typescript-eslint",
"import",
"jsx-a11y",
"prettier",
"react",
"react-hooks"
],

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

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

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

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

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

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

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

@ -1,5 +1,5 @@
import { Panel } from '../Panels';
import PeriodicFetch from '../../lib/fetchers/periodicFetch';
import periodicFetch from '../../lib/fetchers/periodicFetch';
import SelectedAnvil from './SelectedAnvil';
import AnvilList from './AnvilList';
@ -9,7 +9,7 @@ const Anvils = ({ list }: { list: AnvilList | undefined }): JSX.Element => {
const anvils: 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}`,
);
anvils.push({

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

@ -0,0 +1,14 @@
import { LinearProgress } from '@mui/material';
import { styled } from '@mui/material/styles';
import {
PANEL_BACKGROUND,
BORDER_RADIUS,
} from '../../lib/consts/DEFAULT_THEME';
const BorderLinearProgress = styled(LinearProgress)({
height: '1em',
borderRadius: BORDER_RADIUS,
backgroundColor: PANEL_BACKGROUND,
});
export default BorderLinearProgress;

@ -1,57 +1,55 @@
import { makeStyles, withStyles } from '@material-ui/core/styles';
import { LinearProgress } from '@material-ui/core';
import {
PURPLE,
BLUE,
PANEL_BACKGROUND,
BORDER_RADIUS,
} from '../../lib/consts/DEFAULT_THEME';
import { LinearProgress } from '@mui/material';
import { styled } from '@mui/material/styles';
const completed = 100;
import { PURPLE, BLUE, BORDER_RADIUS } from '../../lib/consts/DEFAULT_THEME';
import BorderLinearProgress from './BorderLinearProgress';
const BorderLinearProgress = withStyles({
root: {
height: '1em',
borderRadius: BORDER_RADIUS,
},
colorPrimary: {
backgroundColor: PANEL_BACKGROUND,
},
bar: {
borderRadius: BORDER_RADIUS,
},
})(LinearProgress);
const PREFIX = 'ProgressBar';
const classes = {
barOk: `${PREFIX}-barOk`,
barInProgress: `${PREFIX}-barInProgress`,
underline: `${PREFIX}-underline`,
};
const useStyles = makeStyles(() => ({
barOk: {
const StyledDiv = styled('div')(() => ({
[`& .${classes.barOk}`]: {
backgroundColor: BLUE,
},
barInProgress: {
[`& .${classes.barInProgress}`]: {
backgroundColor: PURPLE,
},
[`& .${classes.underline}`]: {
borderRadius: BORDER_RADIUS,
},
}));
const completed = 100;
const ProgressBar = ({
progressPercentage,
}: {
progressPercentage: number;
}): JSX.Element => {
const classes = useStyles();
return (
<>
<BorderLinearProgress
classes={{
bar:
progressPercentage < completed
? classes.barInProgress
: classes.barOk,
}}
variant="determinate"
value={progressPercentage}
/>
<LinearProgress variant="determinate" value={0} />
</>
);
};
}): JSX.Element => (
<StyledDiv>
<BorderLinearProgress
classes={{
bar:
progressPercentage < completed
? classes.barInProgress
: classes.barOk,
}}
variant="determinate"
value={progressPercentage}
/>
<LinearProgress
className={classes.underline}
variant="determinate"
value={0}
/>
</StyledDiv>
);
export default ProgressBar;

@ -1,15 +1,15 @@
import { useContext } from 'react';
import { Box } from '@material-ui/core';
import { Box } from '@mui/material';
import { Panel } from './Panels';
import { HeaderText, BodyText } from './Text';
import PeriodicFetch from '../lib/fetchers/periodicFetch';
import periodicFetch from '../lib/fetchers/periodicFetch';
import { AnvilContext } from './AnvilContext';
import Spinner from './Spinner';
const CPU = (): JSX.Element => {
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}`,
);

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

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

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

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

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

@ -1,21 +1,31 @@
import { Box } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import { Box } from '@mui/material';
import { styled } from '@mui/material/styles';
import * as prettyBytes from 'pretty-bytes';
import { AllocationBar } from '../Bars';
import { BodyText } from '../Text';
import Decorator from '../Decorator';
const useStyles = makeStyles(() => ({
fs: {
const PREFIX = 'SharedStorageHost';
const classes = {
fs: `${PREFIX}-fs`,
bar: `${PREFIX}-bar`,
decoratorBox: `${PREFIX}-decoratorBox`,
};
const StyledDiv = styled('div')(() => ({
[`& .${classes.fs}`]: {
paddingLeft: '.7em',
paddingRight: '.7em',
paddingTop: '1.2em',
},
bar: {
[`& .${classes.bar}`]: {
paddingLeft: '.7em',
paddingRight: '.7em',
},
decoratorBox: {
[`& .${classes.decoratorBox}`]: {
paddingRight: '.3em',
},
}));
@ -24,57 +34,54 @@ const SharedStorageHost = ({
host,
}: {
host: AnvilFileSystemHost;
}): JSX.Element => {
const classes = useStyles();
return (
<>
<Box display="flex" width="100%" className={classes.fs}>
<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>
}): JSX.Element => (
<StyledDiv>
<Box display="flex" width="100%" className={classes.fs}>
<Box flexGrow={1}>
<BodyText text={host.host_name || 'Not Available'} />
</Box>
{host.is_mounted && (
<>
<Box display="flex" width="100%" className={classes.fs}>
<Box flexGrow={1}>
<BodyText
text={`Used: ${prettyBytes.default(host.total - host.free, {
binary: true,
})}`}
/>
</Box>
<Box>
<BodyText
text={`Free: ${prettyBytes.default(host.free, {
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 className={classes.decoratorBox}>
<Decorator colour={host.is_mounted ? 'ok' : 'error'} />
</Box>
<Box>
<BodyText text={host.is_mounted ? 'Mounted' : 'Not Mounted'} />
</Box>
</Box>
{host.is_mounted && (
<>
<Box display="flex" width="100%" className={classes.fs}>
<Box flexGrow={1}>
<BodyText
text={`Used: ${prettyBytes.default(host.total - host.free, {
binary: true,
})}`}
/>
</Box>
<Box display="flex" justifyContent="center" width="100%">
<Box>
<BodyText
text={`Total Storage: ${prettyBytes.default(host.total, {
text={`Free: ${prettyBytes.default(host.free, {
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 display="flex" justifyContent="center" width="100%">
<BodyText
text={`Total Storage: ${prettyBytes.default(host.total, {
binary: true,
})}`}
/>
</Box>
</>
)}
</StyledDiv>
);
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 AppBar from '@material-ui/core/AppBar';
import { makeStyles } from '@material-ui/core/styles';
import { Box, Button } from '@material-ui/core';
import { styled } from '@mui/material/styles';
import { AppBar, Box, Button } from '@mui/material';
import { ICONS, ICON_SIZE } from '../lib/consts/ICONS';
import { BORDER_RADIUS, RED } from '../lib/consts/DEFAULT_THEME';
import AnvilDrawer from './AnvilDrawer';
const useStyles = makeStyles((theme) => ({
appBar: {
paddingTop: theme.spacing(0.5),
paddingBottom: theme.spacing(0.5),
paddingLeft: theme.spacing(3),
paddingRight: theme.spacing(3),
borderBottom: 'solid 1px',
borderBottomColor: RED,
},
input: {
const PREFIX = 'Header';
const classes = {
input: `${PREFIX}-input`,
barElement: `${PREFIX}-barElement`,
iconBox: `${PREFIX}-iconBox`,
searchBar: `${PREFIX}-searchBar`,
icons: `${PREFIX}-icons`,
};
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',
width: '30vw',
backgroundColor: theme.palette.secondary.main,
borderRadius: BORDER_RADIUS,
},
barElement: {
[`& .${classes.barElement}`]: {
padding: 0,
},
iconBox: {
[`& .${classes.iconBox}`]: {
[theme.breakpoints.down('sm')]: {
display: 'none',
},
},
searchBar: {
[`& .${classes.searchBar}`]: {
[theme.breakpoints.down('sm')]: {
flexGrow: 1,
paddingLeft: '15vw',
},
},
icons: {
[`& .${classes.icons}`]: {
paddingLeft: '.1em',
paddingRight: '.1em',
},
}));
const Header = (): JSX.Element => {
const classes = useStyles();
const [open, setOpen] = useState(false);
const toggleDrawer = (): void => setOpen(!open);
return (
<AppBar position="static" className={classes.appBar}>
<StyledAppBar>
<Box display="flex" justifyContent="space-between" flexDirection="row">
<Box className={classes.barElement}>
<Button onClick={toggleDrawer}>
@ -80,7 +92,7 @@ const Header = (): JSX.Element => {
</Box>
</Box>
<AnvilDrawer open={open} setOpen={setOpen} />
</AppBar>
</StyledAppBar>
);
};

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

@ -2,7 +2,7 @@ import { useContext } from 'react';
import { Panel } from '../Panels';
import { HeaderText } from '../Text';
import AnvilHost from './AnvilHost';
import PeriodicFetch from '../../lib/fetchers/periodicFetch';
import periodicFetch from '../../lib/fetchers/periodicFetch';
import { AnvilContext } from '../AnvilContext';
import Spinner from '../Spinner';
import hostsSanitizer from '../../lib/sanitizers/hostsSanitizer';
@ -10,7 +10,7 @@ import hostsSanitizer from '../../lib/sanitizers/hostsSanitizer';
const Hosts = ({ anvil }: { anvil: AnvilListItem[] }): JSX.Element => {
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}`,
);

@ -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 { Box } from '@material-ui/core';
import { Box } from '@mui/material';
import * as prettyBytes from 'pretty-bytes';
import { Panel } from './Panels';
import { AllocationBar } from './Bars';
import { HeaderText, BodyText } from './Text';
import PeriodicFetch from '../lib/fetchers/periodicFetch';
import periodicFetch from '../lib/fetchers/periodicFetch';
import { AnvilContext } from './AnvilContext';
import Spinner from './Spinner';
const Memory = (): JSX.Element => {
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}`,
);

@ -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 { Box, Divider } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import { Box, Divider } from '@mui/material';
import { styled } from '@mui/material/styles';
import { Panel } from '../Panels';
import { HeaderText, BodyText } from '../Text';
import PeriodicFetch from '../../lib/fetchers/periodicFetch';
import periodicFetch from '../../lib/fetchers/periodicFetch';
import {
DIVIDER,
LARGE_MOBILE_BREAKPOINT,
@ -13,8 +13,18 @@ import { AnvilContext } from '../AnvilContext';
import Decorator, { Colours } from '../Decorator';
import Spinner from '../Spinner';
const useStyles = makeStyles((theme) => ({
container: {
const PREFIX = 'Network';
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%',
overflow: 'auto',
height: '32vh',
@ -24,17 +34,21 @@ const useStyles = makeStyles((theme) => ({
overflow: 'hidden',
},
},
root: {
[`& .${classes.root}`]: {
paddingTop: '.7em',
paddingBottom: '.7em',
},
noPaddingLeft: {
[`& .${classes.noPaddingLeft}`]: {
paddingLeft: 0,
},
divider: {
background: DIVIDER,
[`& .${classes.divider}`]: {
backgroundColor: DIVIDER,
},
verticalDivider: {
[`& .${classes.verticalDivider}`]: {
height: '3.5em',
},
}));
@ -54,21 +68,20 @@ const selectDecorator = (state: string): Colours => {
const Network = (): JSX.Element => {
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}`,
);
const processed = processNetworkData(data);
return (
<Panel>
<HeaderText text="Network" />
{!isLoading ? (
<Box className={classes.container}>
{data &&
processed.bonds.map((bond: ProcessedBond) => {
return (
<StyledDiv>
<HeaderText text="Network" />
{!isLoading ? (
<Box className={classes.container}>
{data &&
processed.bonds.map((bond: ProcessedBond) => (
<>
<Box
display="flex"
@ -114,12 +127,12 @@ const Network = (): JSX.Element => {
</Box>
<Divider className={classes.divider} />
</>
);
})}
</Box>
) : (
<Spinner />
)}
))}
</Box>
) : (
<Spinner />
)}
</StyledDiv>
</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 { Box } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import { Box } from '@mui/material';
import { styled } from '@mui/material/styles';
import { BORDER_RADIUS, DIVIDER } from '../../lib/consts/DEFAULT_THEME';
const StyledBox = styled(Box)({
borderWidth: '1px',
borderRadius: BORDER_RADIUS,
borderStyle: 'solid',
borderColor: DIVIDER,
marginTop: '1.4em',
marginBottom: '1.4em',
paddingBottom: 0,
position: 'relative',
});
type Props = {
children: ReactNode;
};
const useStyles = makeStyles(() => ({
innerBody: {
borderWidth: '1px',
borderRadius: BORDER_RADIUS,
borderStyle: 'solid',
borderColor: DIVIDER,
marginTop: '1.4em',
marginBottom: '1.4em',
paddingBottom: 0,
position: 'relative',
},
}));
const InnerPanel = ({ children }: Props): JSX.Element => {
const classes = useStyles();
return <Box className={classes.innerBody}>{children}</Box>;
};
const InnerPanel = ({ children }: Props): JSX.Element => (
<StyledBox>{children}</StyledBox>
);
export default InnerPanel;

@ -0,0 +1,42 @@
import { ReactNode } from 'react';
import { Box, styled } from '@mui/material';
import { BORDER_RADIUS, DIVIDER } from '../../lib/consts/DEFAULT_THEME';
const PREFIX = 'InnerPanelHeader';
const classes = {
header: `${PREFIX}-header`,
};
const StyledBox = styled(Box)(() => ({
position: 'relative',
padding: '0 .7em',
whiteSpace: 'pre-wrap',
[`& .${classes.header}`]: {
top: '-.3em',
left: '-.3em',
padding: '1.4em 0',
position: 'absolute',
content: '""',
borderColor: DIVIDER,
borderWidth: '1px',
borderRadius: BORDER_RADIUS,
borderStyle: 'solid',
width: '100%',
},
}));
type Props = {
children: ReactNode;
};
const InnerPanelHeader = ({ children }: Props): JSX.Element => (
<StyledBox>
<div className={classes.header} />
{children}
</StyledBox>
);
export default InnerPanelHeader;

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

@ -1,40 +1,15 @@
import { ReactNode } from 'react';
import { Box } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import { BORDER_RADIUS, DIVIDER } from '../../lib/consts/DEFAULT_THEME';
type Props = {
children: ReactNode;
};
const useStyles = makeStyles(() => ({
innerHeader: {
position: 'relative',
padding: '0 .7em',
},
header: {
top: '-.3em',
left: '-.3em',
padding: '1.4em 0',
position: 'absolute',
content: '""',
borderColor: DIVIDER,
borderWidth: '1px',
borderRadius: BORDER_RADIUS,
borderStyle: 'solid',
width: '100%',
import { Box, styled } from '@mui/material';
const PanelHeader = styled(Box)({
alignItems: 'center',
display: 'flex',
flexDirection: 'row',
marginBottom: '1em',
width: '100%',
'& > :first-child': { flexGrow: 1 },
'& > :not(:first-child, :last-child)': {
marginRight: '.3em',
},
}));
const PanelHeader = ({ children }: Props): JSX.Element => {
const classes = useStyles();
return (
<Box className={classes.innerHeader} whiteSpace="pre-wrap">
<div className={classes.header} />
{children}
</Box>
);
};
});
export default PanelHeader;

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

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

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

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

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

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

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

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

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

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

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

@ -2,7 +2,7 @@ export const ICONS = [
{
text: 'Files',
image: '/pngs/files_on.png',
uri: '/striker?files=true',
uri: '/../file-manager',
},
{
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> => {
return fetch(...args).then((response: Response) => response.json());
};
const fetchJSON = <T>(...args: [RequestInfo, RequestInit?]): Promise<T> =>
fetch(...args).then((response: Response) => response.json());
export default fetchJSON;

@ -1,13 +1,20 @@
import useSWR from 'swr';
import useSWR, { SWRConfiguration } from 'swr';
import fetcher from './fetchJSON';
const PeriodicFetch = <T>(
const periodicFetch = <T>(
url: string,
refreshInterval = 5000,
{ refreshInterval = 5000, onSuccess }: SWRConfiguration = {},
): 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, {
refreshInterval,
onSuccess,
});
return {
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 */
const putFetch = <T>(uri: string, data: T): Promise<any> => {
return fetch(uri, {
const putFetch = <T>(uri: string, data: T): Promise<any> =>
fetch(uri, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
};
export default putFetch;

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

@ -0,0 +1,7 @@
import { Axios } from 'axios';
const mainAxiosInstance = new Axios({
baseURL: process.env.NEXT_PUBLIC_API_URL?.replace('/cgi-bin', '/api'),
});
export default mainAxiosInstance;

@ -1 +0,0 @@
self.__BUILD_MANIFEST=function(e){return{__rewrites:{beforeFiles:[],afterFiles:[],fallback:[]},"/":[e,"static/chunks/291-3b847a192c168e5d.js","static/chunks/pages/index-a78fc9f55265c4ea.js"],"/_error":["static/chunks/pages/_error-2280fa386d040b66.js"],"/server":[e,"static/chunks/145-170d45ccc7e94584.js","static/chunks/pages/server-b964c24fd9a69b1e.js"],sortedPages:["/","/_app","/_error","/server"]}}("static/chunks/321-7f3df35ed02396a1.js"),self.__BUILD_MANIFEST_CB&&self.__BUILD_MANIFEST_CB();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save