diff --git a/cypress/e2e/phase_4/rootAdmin.cy.js b/cypress/e2e/phase_4/rootAdmin.cy.js index 9ddac2d65..59d5b4ad4 100644 --- a/cypress/e2e/phase_4/rootAdmin.cy.js +++ b/cypress/e2e/phase_4/rootAdmin.cy.js @@ -16,16 +16,12 @@ describe('RootAdmin', () => { // Create a new instance // Find button ajouter - cy.get('button') - .contains('Ajouter') - .click(); + cy.get('button').contains('Ajouter').click(); // Fill the form cy.get('input[id="tenant-name-field"]').type('tenant-1'); cy.get('input[id="tenant-description-field"]').type('Description'); cy.get('input[id="tenant-author-field"]').type('Author'); - cy.get('button') - .contains('Créer') - .click(); + cy.get('button').contains('Créer').click(); // Should display the new instance with login form cy.visit('http://localhost:3000/instance/tenant-1/'); diff --git a/package-lock.json b/package-lock.json index a42764699..bb95af150 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@bull-board/koa": "^5.9.1", "@dnd-kit/core": "6.0.6", "@dnd-kit/sortable": "7.0.1", + "@draconides/format": "1.0.3", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", "@ezs/analytics": "2.3.2", @@ -2821,6 +2822,19 @@ "react": ">=16.8.0" } }, + "node_modules/@draconides/format": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@draconides/format/-/format-1.0.3.tgz", + "integrity": "sha512-YqCgmbmtgKXEG65+IubmScfj0A6GcgceYS9oxTSWiz86he4E+EC1wxUeEv08x9VHPOTf1ILoyyg+3wxG2rVyMA==", + "dependencies": { + "@draconides/unit": "^1.0.2" + } + }, + "node_modules/@draconides/unit": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@draconides/unit/-/unit-1.0.2.tgz", + "integrity": "sha512-tQzprPLX0LeRl779JSEJA92CeZVe6DGle3lXSJi05dWteIla9I6c2RjAjrhpNSWSv+G8nsCbg5e1oGtr8ifVaw==" + }, "node_modules/@emotion/babel-plugin": { "version": "11.7.2", "license": "MIT", diff --git a/package.json b/package.json index 32cc2581d..f32b22d8f 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@bull-board/koa": "^5.9.1", "@dnd-kit/core": "6.0.6", "@dnd-kit/sortable": "7.0.1", + "@draconides/format": "1.0.3", "@emotion/react": "11.7.1", "@emotion/styled": "11.6.0", "@ezs/analytics": "2.3.2", diff --git a/src/api/controller/rootAdmin.js b/src/api/controller/rootAdmin.js index 67eef616e..3887e8255 100644 --- a/src/api/controller/rootAdmin.js +++ b/src/api/controller/rootAdmin.js @@ -6,13 +6,14 @@ import { auth } from 'config'; import { ObjectId } from 'mongodb'; import { createWorkerQueue, deleteWorkerQueue } from '../workers'; import { - ROOT_ROLE, checkForbiddenNames, checkNameTooLong, + ROOT_ROLE, } from '../../common/tools/tenantTools'; import bullBoard from '../bullBoard'; import { insertConfigTenant } from '../services/configTenant'; import mongoClient from '../services/mongoClient'; +import os from 'os'; const app = new Koa(); app.use( @@ -51,7 +52,7 @@ const getTenants = async (ctx, filter) => { for (const tenant of tenants) { const db = await mongoClient(tenant.name); - tenant.totalSize = (await db.stats({ scale: 1024 })).totalSize; + tenant.totalSize = (await db.stats()).totalSize; try { tenant.dataset = await db.collection('dataset').find().count(); @@ -136,11 +137,52 @@ const deleteTenant = async (ctx) => { } }; +// https://stackoverflow.com/questions/36816181/get-view-memory-cpu-usage-via-nodejs +// Initial value; wait at little amount of time before making a measurement. +let timesBefore = os.cpus().map((c) => c.times); + +// Call this function periodically, e.g. using setInterval, +function getAverageUsage() { + let timesAfter = os.cpus().map((c) => c.times); + let timeDeltas = timesAfter.map((t, i) => ({ + user: t.user - timesBefore[i].user, + sys: t.sys - timesBefore[i].sys, + idle: t.idle - timesBefore[i].idle, + })); + + timesBefore = timesAfter; + + return ( + timeDeltas + .map( + (times) => + 1 - times.idle / (times.user + times.sys + times.idle), + ) + .reduce((l1, l2) => l1 + l2) / timeDeltas.length + ); +} + +const systemInfo = async (ctx) => { + const dbStats = await ctx.rootAdminDb.stats(); + ctx.body = { + cpu: os.cpus().length, + load: getAverageUsage(), + database: { + total: dbStats.fsTotalSize, + use: dbStats.fsUsedSize, + }, + totalmem: os.totalmem(), + freemem: os.freemem(), + }; +}; + app.use(route.get('/tenant', getTenant)); app.use(route.post('/tenant', postTenant)); app.use(route.put('/tenant/:id', putTenant)); app.use(route.delete('/tenant', deleteTenant)); +app.use(route.get('/system', systemInfo)); + app.use(async (ctx) => { ctx.status = 404; }); diff --git a/src/app/js/root-admin/CreateTenantDialog.js b/src/app/js/root-admin/CreateTenantDialog.js index 380a92c61..f9343e1ac 100644 --- a/src/app/js/root-admin/CreateTenantDialog.js +++ b/src/app/js/root-admin/CreateTenantDialog.js @@ -1,94 +1,154 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { Dialog, DialogContent, - DialogActions, DialogTitle, Button, - InputLabel, FormControl, FormHelperText, TextField, + Box, } from '@mui/material'; -import NameField from './NameField'; +import { deburr } from 'lodash'; + import { checkForbiddenNames, forbiddenNamesMessage, getTenantMaxSize, - MAX_DB_NAME_SIZE, } from '../../../common/tools/tenantTools'; +const cleanUpName = (name) => { + // We replace any accented and special char with the base letter or a dash + // https://stackoverflow.com/questions/36557202/replacing-special-characters-with-dashes + + return deburr(name) + .replace(/[\s_\W]+/g, '-') + .replace(/^-/, '') + .substring(0, getTenantMaxSize(window.__DBNAME__)) + .toLowerCase(); +}; + const CreateTenantDialog = ({ isOpen, handleClose, createAction }) => { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [author, setAuthor] = useState(''); + useEffect(() => { + if (isOpen) { + setName(''); + setDescription(''); + setAuthor(''); + } + }, [isOpen]); + + const handleName = (event) => { + setName(cleanUpName(event.target.value)); + }; + + const handleDescription = (event) => { + setDescription(event.target.value); + }; + + const handleAuthor = (event) => { + setAuthor(event.target.value); + }; + + const handleSubmit = (event) => { + event.preventDefault(); + createAction({ name: cleanUpName(name), description, author }); + }; + return ( - + Créer une nouvelle instance - - - Nom - setName(event.target.value)} - error={checkForbiddenNames(name)} - value={name} - /> - - Une instance ne peut pas être nommée{' '} - {forbiddenNamesMessage}. Pour composer le nom, seules - les lettres en minuscules, les chiffres et le tiret "-" - sont autorisés. Une limitation en nombre de caractères - est automatiquement appliquée en fonction du nom du - container de l’instance ( - {getTenantMaxSize(window.__DBNAME__)} caractères). - - - - setDescription(event.target.value)} - value={description} - sx={{ marginTop: '1em' }} - /> - - setAuthor(event.target.value)} - value={author} - sx={{ marginTop: '1em' }} - /> + +
+ + + + Une instance ne peut pas être nommée{' '} + {forbiddenNamesMessage}. Pour composer le nom, + seules les lettres en minuscules, les chiffres et le + tiret "-" sont autorisés. Une limitation + en nombre de caractères est automatiquement + appliquée en fonction du nom du container de + l’instance ({getTenantMaxSize(window.__DBNAME__)}{' '} + caractères). + + + + + + + + + + + + + + + +
- - -
); }; diff --git a/src/app/js/root-admin/DeleteTenantDialog.js b/src/app/js/root-admin/DeleteTenantDialog.js index 7e7adc3e3..d4fd413a0 100644 --- a/src/app/js/root-admin/DeleteTenantDialog.js +++ b/src/app/js/root-admin/DeleteTenantDialog.js @@ -1,23 +1,49 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { TextField, Dialog, DialogContent, - DialogActions, DialogTitle, Button, FormControlLabel, Checkbox, + Box, } from '@mui/material'; -const DeleteTenantDialog = ({ tenant, handleClose, deleteAction }) => { +const DeleteTenantDialog = ({ isOpen, tenant, handleClose, deleteAction }) => { const [name, setName] = useState(''); const [deleteDatabase, setDeleteDatabase] = useState(true); + const [validationOnError, setValidationOnError] = useState(false); + + useEffect(() => { + if (isOpen) { + setName(''); + setDeleteDatabase(true); + } + }, [isOpen]); + + const handleTextValidation = (event) => { + setName(event.target.value); + if (event.target.value !== tenant.name) { + setValidationOnError(true); + } else { + setValidationOnError(false); + } + }; + + const handleDatabaseDeletion = () => { + setDeleteDatabase(!deleteDatabase); + }; + + const handleSubmit = (event) => { + event.preventDefault(); + deleteAction(tenant._id, tenant.name, deleteDatabase); + }; return ( { Confirmer la suppression de : {tenant.name} - - setName(event.target.value)} - error={name !== tenant.name} - value={name} - /> - { - setDeleteDatabase(!deleteDatabase); + +
+ + + + + + + } + label="Supprimer la base de données correspondante" + labelPlacement="end" + /> + + + + + + +
- - -
); }; DeleteTenantDialog.propTypes = { + isOpen: PropTypes.bool.isRequired, tenant: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]).isRequired, handleClose: PropTypes.func.isRequired, deleteAction: PropTypes.func.isRequired, diff --git a/src/app/js/root-admin/LoginForm.js b/src/app/js/root-admin/LoginForm.js index 4aefdf9aa..98eccb76e 100644 --- a/src/app/js/root-admin/LoginForm.js +++ b/src/app/js/root-admin/LoginForm.js @@ -56,7 +56,7 @@ const LoginForm = () => { )}
onChange({ target: { name, value } })} - /> - ); -}); - -const NameField = (inputProps) => { - const { defaultValue } = inputProps; - return ( - - ); -}; - -TextMaskCustom.propTypes = { - onChange: PropTypes.func.isRequired, - name: PropTypes.string, -}; - -export default NameField; diff --git a/src/app/js/root-admin/SystemLoad.js b/src/app/js/root-admin/SystemLoad.js new file mode 100644 index 000000000..6f89c9233 --- /dev/null +++ b/src/app/js/root-admin/SystemLoad.js @@ -0,0 +1,241 @@ +import React, { useEffect, useState } from 'react'; +import CircularProgress from '@mui/material/CircularProgress'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/material/Box'; +import PropTypes from 'prop-types'; +import { sizeConverter } from './rootAdminUtils'; +import { Tooltip } from '@mui/material'; +import MemoryIcon from '@mui/icons-material/Memory'; +import StorageIcon from '@mui/icons-material/Storage'; +import SdStorageIcon from '@mui/icons-material/SdStorage'; + +const greenColor = { + red: 19, + green: 233, + blue: 19, +}; +const yellowColor = { + red: 255, + green: 255, + blue: 0, +}; +const redColor = { + red: 255, + green: 0, + blue: 0, +}; + +// https://stackoverflow.com/questions/30143082/how-to-get-color-value-from-gradient-by-percentage-with-javascript +// https://gist.github.com/gskema/2f56dc2e087894ffc756c11e6de1b5ed +const colorGradient = (fadeFraction, rgbColor1, rgbColor2, rgbColor3) => { + let color1 = rgbColor1; + let color2 = rgbColor2; + let fade = fadeFraction; + + // Do we have 3 colours for the gradient? Need to adjust the params. + if (rgbColor3) { + fade = fade * 2; + + // Find which interval to use and adjust the fade percentage + if (fade >= 1) { + fade -= 1; + color1 = rgbColor2; + color2 = rgbColor3; + } + } + + const diffRed = color2.red - color1.red; + const diffGreen = color2.green - color1.green; + const diffBlue = color2.blue - color1.blue; + + const red = Math.floor(color1.red + diffRed * fade); + const green = Math.floor(color1.green + diffGreen * fade); + const blue = Math.floor(color1.blue + diffBlue * fade); + + return `rgb(${red},${green},${blue})`; +}; + +const CircularProgressWithLabel = ({ value }) => { + return ( + + + + + + + + + {`${Math.round(value)}%`} + + + + ); +}; + +CircularProgressWithLabel.propTypes = { + /** + * The value of the progress indicator for the determinate variant. + * Value between 0 and 100. + * @default 0 + */ + value: PropTypes.number.isRequired, +}; + +const SystemLoad = () => { + const [loadTitle, setLoadTitle] = useState(''); + const [loadAvg, setLoadAvg] = useState(0); + + const [memTitle, setMemTile] = useState(''); + const [memUsage, setMemUsage] = useState(0); + + const [storageTitle, setStorageTile] = useState(''); + const [storageUsage, setStorageUsage] = useState(0); + + const fetchSystemLoad = () => { + fetch('/rootAdmin/system', { + credentials: 'include', + }) + .then((response) => response.json()) + .then((data) => { + setLoadTitle( + `Processeur : utilisation ${Math.round(data.load * 100)}% - ${data.cpu} cœur${data.cpu === 1 ? '' : 's'}`, + ); + setLoadAvg(data.load * 100); + + const totalMem = data.totalmem; + const usedMem = data.totalmem - data.freemem; + const memPercent = (100 * usedMem) / totalMem; + + setMemUsage(memPercent); + setMemTile( + `Mémoire : ${sizeConverter(usedMem)} / ${sizeConverter(totalMem)}`, + ); + + const storagePercent = + (100 * data.database.use) / data.database.total; + const totalStorage = sizeConverter(data.database.total); + const usedStorage = sizeConverter(data.database.use); + + setStorageUsage(storagePercent); + setStorageTile(`Stockage : ${usedStorage} / ${totalStorage}`); + }); + }; + + // Fetch on loads + useEffect(() => { + fetchSystemLoad(); + }, []); + + // Fetch evey 2 seconds + useEffect(() => { + const timer = setInterval(() => { + fetchSystemLoad(); + }, 2000); + return () => { + clearInterval(timer); + }; + }, []); + + return ( + <> + +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ + ); +}; + +export default SystemLoad; diff --git a/src/app/js/root-admin/Tenants.js b/src/app/js/root-admin/Tenants.js index 4f4a42052..ac868a949 100644 --- a/src/app/js/root-admin/Tenants.js +++ b/src/app/js/root-admin/Tenants.js @@ -26,6 +26,7 @@ import { Typography, } from '@mui/material'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; +import { sizeConverter } from './rootAdminUtils'; const baseUrl = getHost(); @@ -386,21 +387,7 @@ const Tenants = ({ handleLogout }) => { flex: 2, sortable: true, valueFormatter: (params) => { - if (params.value == null) { - return '-'; - } - - const mbSize = (params.value / 1024).toFixed(2); - - if (mbSize > 1024) { - return `${(mbSize / 1024).toFixed(2)} Gio`; - } - - if (mbSize > 1) { - return `${mbSize} Mio`; - } - - return `${params.value} Kio`; + return sizeConverter(params.value); }, }, { @@ -442,7 +429,13 @@ const Tenants = ({ handleLogout }) => { return ( <> -
+
row._id} rows={tenants} @@ -463,12 +456,14 @@ const Tenants = ({ handleLogout }) => { createAction={addTenant} /> setTenantToUpdate(null)} updateAction={updateTenant} /> setOpenDeleteTenantDialog(false)} deleteAction={deleteTenant} diff --git a/src/app/js/root-admin/UpdateTenantDialog.js b/src/app/js/root-admin/UpdateTenantDialog.js index e2752776b..4b754e788 100644 --- a/src/app/js/root-admin/UpdateTenantDialog.js +++ b/src/app/js/root-admin/UpdateTenantDialog.js @@ -3,98 +3,145 @@ import PropTypes from 'prop-types'; import { Dialog, DialogContent, - DialogActions, DialogTitle, Button, TextField, + Box, } from '@mui/material'; -const UpdateTenantDialog = ({ tenant, handleClose, updateAction }) => { +const UpdateTenantDialog = ({ isOpen, tenant, handleClose, updateAction }) => { const [description, setDescription] = useState(''); const [author, setAuthor] = useState(''); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); useEffect(() => { - setDescription(tenant?.description || ''); - setAuthor(tenant?.author || ''); - setUsername(tenant?.username || ''); - setPassword(tenant?.password || ''); - }, [tenant]); + if (isOpen) { + setDescription(tenant?.description || ''); + setAuthor(tenant?.author || ''); + setUsername(tenant?.username || ''); + setPassword(tenant?.password || ''); + } + }, [isOpen, tenant]); + + const handleDescription = (event) => { + setDescription(event.target.value); + }; + + const handleAuthor = (event) => { + setAuthor(event.target.value); + }; + + const handleUsername = (event) => { + setUsername(event.target.value); + }; + + const handlePassword = (event) => { + setPassword(event.target.value); + }; + + const handleSubmit = (event) => { + event.preventDefault(); + updateAction(tenant._id, { + description, + author, + username, + password, + }); + }; return ( Modifier instance: {tenant?.name} - - setDescription(event.target.value)} - value={description} - sx={{ marginTop: '1em' }} - /> + + + + + + + + + + + + + + + + + - setAuthor(event.target.value)} - value={author} - sx={{ marginTop: '1em' }} - /> - setUsername(event.target.value)} - value={username} - sx={{ marginTop: '1em' }} - /> - setPassword(event.target.value)} - value={password} - sx={{ marginTop: '1em' }} - /> + + + + + - - - ); }; UpdateTenantDialog.propTypes = { + isOpen: PropTypes.bool.isRequired, tenant: PropTypes.object, handleClose: PropTypes.func.isRequired, updateAction: PropTypes.func.isRequired, diff --git a/src/app/js/root-admin/index.js b/src/app/js/root-admin/index.js index aa9d25b25..12e600a19 100644 --- a/src/app/js/root-admin/index.js +++ b/src/app/js/root-admin/index.js @@ -23,6 +23,7 @@ import ExitToAppIcon from '@mui/icons-material/ExitToApp'; import Tenants from './Tenants'; import LoginForm from './LoginForm'; import { ROOT_ROLE } from '../../../common/tools/tenantTools'; +import SystemLoad from './SystemLoad'; const localesMUI = new Map([ ['fr', { ...frFR, ...frFRDatagrid }], @@ -31,7 +32,7 @@ const localesMUI = new Map([ const locale = getLocale(); -export default function RootAdmin() { +function RootAdmin() { const [isLoggedIn, setIsLoggedIn] = useState(false); const [role, setRole] = useState(''); @@ -68,25 +69,35 @@ export default function RootAdmin() { - + Configuration des instances {isLoggedIn && ( - + <> + + + )} diff --git a/src/app/js/root-admin/rootAdminUtils.js b/src/app/js/root-admin/rootAdminUtils.js new file mode 100644 index 000000000..454c3b22d --- /dev/null +++ b/src/app/js/root-admin/rootAdminUtils.js @@ -0,0 +1,14 @@ +import { formatBytes } from '@draconides/format'; + +const numberFormatter = new Intl.NumberFormat('fr-FR'); + +export const sizeConverter = (value) => { + if (value == null) { + return '-'; + } + + return formatBytes(Number(value), { + style: 'octet', + formatter: numberFormatter.format, + }); +}; diff --git a/src/app/root-admin.ejs b/src/app/root-admin.ejs index f1fed3da3..387abe87c 100755 --- a/src/app/root-admin.ejs +++ b/src/app/root-admin.ejs @@ -9,9 +9,14 @@ + -
+