Merge remote-tracking branch 'frontend/monorepo' into monorepo

master
Tudor Stanciu 2024-03-24 23:12:41 +02:00
commit 209bb7e91f
121 changed files with 22266 additions and 0 deletions

7
frontend/.dockerignore Normal file
View File

@ -0,0 +1,7 @@
.git
node_modules
build
__mocks__
.vscode
helm
private

8
frontend/.env Normal file
View File

@ -0,0 +1,8 @@
REACT_APP_TUITIO_URL=http://#######
REACT_APP_NETWORK_RESURRECTOR_API_URL=http://#######
#600000 milliseconds = 10 minutes
REACT_APP_MACHINE_PING_INTERVAL=600000
#300000 milliseconds = 5 minutes
REACT_APP_MACHINE_STARTING_TIME=300000

9
frontend/.env.production Normal file
View File

@ -0,0 +1,9 @@
PUBLIC_URL=
REACT_APP_TUITIO_URL=https://#######
REACT_APP_NETWORK_RESURRECTOR_API_URL=https://#######
#900000 milliseconds = 15 minutes
REACT_APP_MACHINE_PING_INTERVAL=900000
#300000 milliseconds = 5 minutes
REACT_APP_MACHINE_STARTING_TIME=300000

38
frontend/.eslintrc.json Normal file
View File

@ -0,0 +1,38 @@
{
"env": {
"browser": true,
"es6": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:jest/recommended"
],
"parser": "@babel/eslint-parser",
"parserOptions": {
"requireConfigFile": false,
"babelOptions": {
"presets": ["@babel/preset-react"]
},
"ecmaFeatures": {
"experimentalObjectRestSpread": true,
"jsx": true
},
"sourceType": "module"
},
"plugins": ["react", "react-hooks", "jest"],
"ignorePatterns": ["**/public"],
"rules": {
"indent": 0,
"linebreak-style": 0,
"quotes": 0,
"semi": 0,
"no-console": 0,
"no-debugger": "warn",
"react/display-name": "off",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"no-unused-vars": [1, { "args": "after-used", "argsIgnorePattern": "^_" }]
}
}

30
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.eslintcache
debug.log
build.sh
buildx.sh

1
frontend/.npmrc Normal file
View File

@ -0,0 +1 @@
@flare:registry=https://lab.code-rove.com/public-node-registry

View File

@ -0,0 +1,7 @@
{
"trailingComma": "none",
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"arrowParens": "avoid"
}

22
frontend/LICENSE Normal file
View File

@ -0,0 +1,22 @@
MIT License
Copyright (c) 2020 Tudor Stanciu
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

87
frontend/README.md Normal file
View File

@ -0,0 +1,87 @@
# Network resurrector
Everything must be able to be managed remotely. Even the powered off servers. That's how Network resurrector appeared, the tool I wrote specifically to be able to wake up my machines that I don't need to be powered on all the time.
Network Resurrector is a system that comprises of five essential services which allow for the execution of its core functionality. To enable various additional features, such as the notification mechanism, supplementary components may be added to the system as an option.
## Main components
### Frontend
- The [frontend](https://lab.code-rove.com/gitea/tudor.stanciu/network-resurrector-frontend) component is a web application written in React JS that has the role of providing the user with a friendly visual interface through which to interact with the system.
### API
- The API component is a .NET 6 REST API that has the role of mediating the exchange of data and commands between the frontend component and the database or server component.
### Server
- The server component is a .NET 6 service specialized in executing 'WakeOnLAN', 'Ping' and 'Shutdown' actions for the machines in its network.
### Agent
- The agent is a .NET 6 service specialized in executing 'Shutdown', 'Restart', 'Sleep', 'Logout' and 'Lock' actions on the host machine on which it is installed. Each action can be executed at the time of launch or with a certain delay. If an action is requested with a delay and later the user changes his mind, he can cancel the action by executing the separate 'Cancel' type action.
- The need for the agent appeared after I realized that the server cannot perform any action on a machine, being outside of it.
- The agent solves this problem because it is installed directly on the targeted machine. Later, the API component can delegate the resolution of an action directly to the agent.
- Most of the time, the execution flow of an action is realized in the following way:
- The user initiates an action, such as starting or stopping a machine, by pressing the corresponding button in the user interface.
- The frontend component sends the command to the API.
- The API checks who is configured as performer for the respective action for the machine and sends the command to it.
- The performer of the action (Agent or Server) executes the command and responds on the flow with status.
- In most cases, the Server component handles the machine startup action while the Agent component manages the machine shutdown action.
- As is probably already obvious, the agent can be installed on as many machines as desired.
### Tuitio
- [Tuitio](https://lab.code-rove.com/gitea/tudor.stanciu/tuitio) is my personal identity server. It manages user authentication within the application and authorizes requests made by it. Further information about [Tuitio](https://lab.code-rove.com/gitea/tudor.stanciu/tuitio/src/branch/master/README.md) can be found on its dedicated page.
All communication between the five main components is done exclusively through HTTP.
## Secondary components
- NATS Streaming - Used to publish notifications about executed actions.
- Correo - Used to send by email the notifications generated by the system.
- Seq - Used to collect and view system logs.
## Notification system
The notification system is focused on system actions (Wake, Shutdown, etc), and the configuration of notifications is done in structures of the following form:
```
{
"To": [ "user@homelab.com" ],
"Subject": "Network resurrector: Machine {MACHINE_NAME} has been waked. Status: {ACTION_STATUS}",
"Body": "Hello,{NEWLINE:2}Network resurrector processed a command to start machine {MACHINE_FULLNAME} with IP {MACHINE_IP} at {SYSTEM_DATETIME}.{NEWLINE}The performer who was delegated for this action was {ACTION_PERFORMER}.{NEWLINE}Action status: {ACTION_STATUS}{NEWLINE:2}Have a nice day,{NEWLINE}Network resurrector notification system."
}
```
The texts can be written at the user's free choice, and the following placeholders can be used in their composition: `{MACHINE_NAME}`, `{MACHINE_FULLNAME}`, `{MACHINE_IP}`, `{ACTION_STATUS}`, `{ACTION_PERFORMER}`, `{SYSTEM_DATETIME}`, `{ERROR_MESSGE}`, `{NEWLINE}`, `{NEWLINE:2}`
`{NEWLINE:x}` is a dynamic placeholder. Any number can be written in place of the 'x' character and the notification system will add that many new lines.
## Database
Currently, the database server supported by the system is only Microsoft SQL Server. In the following versions, the system will also be compatible with PostgreSQL and SQLite.
## Logging
The logging functionality is managed with Serilog, and its configuration is done in the `appsettings.json` file. In addition to its standard configuration, Network resurrector also has a preconfigured area where two destinations for logs are available: SqlServer database and Seq. Each of the destinations can be activated or not. If logging in the console is sufficient, all additional logging destinations can be disabled.
This configuration area is:
```
"Logs": {
"SqlServer": {
"Enabled": false,
"Connection": "Server=<server>;Database=<database>;User Id=<user>;Password=<password>;"
},
"Seq": {
"Enabled": false,
"Url": "",
"ApiKey": ""
}
}
```
## Hosting
All the components of the system are written in cross-platform technologies, so its host can be any environment.

44
frontend/dockerfile Normal file
View File

@ -0,0 +1,44 @@
# BUILD ENVIRONMENT
FROM node:14-slim as builder
WORKDIR /app
ARG APP_SUBFOLDER=""
COPY .npmrc .npmrc
COPY package*.json ./
RUN npm install
RUN rm -f .npmrc
COPY . ./
# build the react app
RUN if [ -z "$APP_SUBFOLDER" ]; then npm run build; else PUBLIC_URL=/${APP_SUBFOLDER}/ npm run build; fi
# PRODUCTION ENVIRONMENT
FROM node:14-slim
ARG APP_SUBFOLDER=""
RUN printf '\n\n- Copy application files\n'
COPY --from=builder /app/build ./application/${APP_SUBFOLDER}
COPY --from=builder /app/build/index.html ./application/
COPY --from=builder /app/setenv.js ./application/setenv.js
#install static server
RUN npm install -g serve
# environment variables
ENV AUTHOR="Tudor Stanciu"
ENV PUBLIC_URL=/${APP_SUBFOLDER}/
ARG APP_VERSION=0.0.0
ENV APP_VERSION=${APP_VERSION}
ARG APP_DATE="-"
ENV APP_DATE=${APP_DATE}
#set workdir to root
WORKDIR /
EXPOSE 80
CMD ["sh", "-c", "node application/setenv.js && serve -s application -p 80"]

6
frontend/jsconfig.json Normal file
View File

@ -0,0 +1,6 @@
{
"compilerOptions": {
"baseUrl": "src"
},
"include": ["src"]
}

17241
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

69
frontend/package.json Normal file
View File

@ -0,0 +1,69 @@
{
"name": "network-resurrector-frontend",
"version": "1.2.7",
"description": "Frontend component of Network resurrector system",
"author": {
"name": "Tudor Stanciu",
"email": "tudor.stanciu94@gmail.com",
"url": "https://lab.code-rove.com/tsp"
},
"repository": {
"type": "git",
"url": "https://lab.code-rove.com/gitea/tudor.stanciu/network-resurrector-frontend"
},
"private": true,
"dependencies": {
"@flare/js-utils": "^1.1.0",
"@flare/tuitio-client-react": "^1.2.6",
"@material-ui/core": "^4.11.2",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.61",
"axios": "^1.3.4",
"i18next": "^19.4.4",
"i18next-browser-languagedetector": "^4.1.1",
"i18next-http-backend": "^1.4.0",
"moment": "^2.29.3",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-flags": "^0.1.18",
"react-i18next": "^11.4.0",
"react-lazylog": "^4.5.3",
"react-router-dom": "^5.2.0",
"react-scripts": "4.0.1",
"react-toastify": "^6.2.0"
},
"devDependencies": {
"@babel/eslint-parser": "^7.16.5",
"@testing-library/jest-dom": "^5.11.6",
"@testing-library/react": "^11.2.2",
"@testing-library/user-event": "^12.5.0",
"prettier": "^2.5.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.3%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version",
">0.3%",
"not dead",
"not op_mini all"
]
}
}

View File

@ -0,0 +1,13 @@
REACT v4:
https://v4.mui.com/getting-started/installation/
https://v4.mui.com/components/material-icons/
Theming:
https://v4.mui.com/customization/palette/ (Dark theme)
Add in settings:
- ping interval
- notifications
- test notification mechanism
- permissions
- permissions hierarchy

2
frontend/public/env.js Normal file
View File

@ -0,0 +1,2 @@
// In this file will be injected the environment variables that will overwrite the application configurations.
window.env = {};

BIN
frontend/public/favicon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 B

View File

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.jpg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
<script src="%PUBLIC_URL%/env.js"></script>
<title>Network resurrector</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View File

@ -0,0 +1,143 @@
{
"DATE": "{{date,intlDate}}",
"LONG_DATE": "{{date,intlLongDate}}",
"DATE_FORMAT": "{{date, format}}",
"TIME_FROM_X": "{{date,intlTimeFromX}}",
"H_FROM_X": "{{date,intlHoursFromX}}",
"H_FROM_M": "{{number,intlHoursFromMinutes}}",
"NUMBER": "{{number,intlNumber}}",
"DECIMAL": "{{number,intlDecimal}}",
"DECIMAL2": "{{number,intlDecimal2}}",
"Language": {
"English": "English",
"Romanian": "Romanian"
},
"Generic": {
"Copy": "Copy",
"OpenInNewTab": "Open in new tab",
"CopiedToClipboard": "Copied to clipboard",
"SendEmail": "Send email"
},
"Menu": {
"Dashboard": "Dashboard",
"Machines": "Machines",
"System": "System",
"Administration": "Administration",
"Settings": "Settings",
"About": "About"
},
"ViewModes": {
"Table": "Table",
"List": "List"
},
"Login": {
"Username": "Username",
"Password": "Password",
"Label": "Login",
"IncorrectCredentials": "Incorrect credentials."
},
"Announcements": {
"NotAllowed": {
"Title": "It seems that you do not have sufficient rights to view this page.",
"Message": "For more details, please contact an administrator."
}
},
"Dashboard": {
"Announcements": {
"Guest": {
"Title": "Hello there! I'm glad you're here!",
"Message": "You are currently browsing my application as a guest. Keep in mind that you cannot perform actions, and most of the data you see is fake. The purpose of the application in this state is for presentation."
},
"User": {
"Title": "Welcome back, {{userName}}",
"Message": "The application is in continuous development, so if you identify a problem, please report it. Thank you!"
}
}
},
"User": {
"Profile": {
"Label": "Profile",
"Hello": "Hi, {{userName}}",
"Description": "{{userName}}, authenticated on {{loginDate}}",
"OpenPortfolio": "Open portfolio",
"Security": {
"UserGroups": "User groups",
"UserRoles": "User roles"
}
},
"Settings": "Settings",
"Logout": "Logout"
},
"Machine": {
"FullName": "Full machine name",
"Name": "Machine name",
"IP": "IP",
"MAC": "MAC address",
"Description": "Description",
"PoweredOn": "Powered on",
"Actions": {
"Wake": "Wake",
"Ping": "Ping",
"More": "More",
"Shutdown": "Shutdown",
"Restart": "Restart",
"Advanced": "Advanced"
}
},
"System": {
"Navigation": {
"MainServices": "Main services",
"Agents": "Agents"
}
},
"Settings": {
"Navigation": {
"System": "System",
"Appearance": "Appearance",
"Notifications": "Notifications"
},
"Cache": {
"Title": "Cache settings",
"Reset": "Reset",
"ResetInfo": "Cache reset."
},
"Appearance": {
"Title": "Appearance settings"
}
},
"About": {
"Navigation": {
"System": "System",
"ReleaseNotes": "Release notes",
"Timeline": "Timeline"
},
"ReleaseNotes": {
"Version": "Version",
"Date": "Date"
},
"System": {
"Description": {
"Title": "Network resurrector system",
"FirstPhrase": "Everything must be able to be managed remotely. Even the powered off servers. That's how Network resurrector appeared, the tool I wrote specifically to be able to wake up my machines that I don't need to be powered on all the time.",
"SecondPhrase": "Network Resurrector is a system that comprises of five essential services which allow for the execution of its core functionality. To enable various additional features, such as the notification mechanism, supplementary components may be added to the system as an option.",
"Frontend": "Frontend: The frontend component is a web application written in React JS that has the role of providing the user with a friendly visual interface through which to interact with the system.",
"Api": "API: The API component is a .NET 6 REST API that has the role of mediating the exchange of data and commands between the frontend component and the database or server component.",
"Server": "Server: The server component is a .NET 6 service specialized in executing 'WakeOnLAN', 'Ping' and 'Shutdown' actions for the machines in its network.",
"Agent": "Agent: The agent is a .NET 6 service specialized in executing 'Shutdown', 'Restart', 'Sleep', 'Logout' and 'Lock' actions on the host machine on which it is installed. Each action can be executed at the time of launch or with a certain delay. If an action is requested with a delay and later the user changes his mind, he can cancel the action by executing the separate 'Cancel' type action.",
"Tuitio": "Tuitio: Tuitio is my personal identity server. It manages user authentication within the application and authorizes requests made by it. Further information about Tuitio can be found on its dedicated page."
},
"Services": {
"Frontend": "Frontend",
"Api": "API",
"Server": "Server",
"Tuitio": "Tuitio"
},
"Version": {
"Server": "Server: {{version}}",
"Api": "API: {{version}}",
"Frontend": "UI: {{version}}",
"LastReleaseDate": "Last update date: {{date}}"
}
}
}
}

View File

@ -0,0 +1,134 @@
{
"Language": {
"English": "Engleză",
"Romanian": "Română"
},
"Generic": {
"Copy": "Copiază",
"OpenInNewTab": "Deschide într-un tab nou",
"CopiedToClipboard": "Copiat în clipboard",
"SendEmail": "Trimite email"
},
"Menu": {
"Dashboard": "Bord",
"Machines": "Mașini",
"System": "Sistem",
"Administration": "Administrare",
"Settings": "Setări",
"About": "Despre"
},
"ViewModes": {
"Table": "Tabel",
"List": "Lista"
},
"Login": {
"Username": "Utilizator",
"Password": "Parolă",
"Label": "Autentificare",
"IncorrectCredentials": "Credențiale incorecte."
},
"Announcements": {
"NotAllowed": {
"Title": "Se pare că nu aveți suficiente drepturi pentru a vizualiza această pagină.",
"Message": "Pentru mai multe detalii, vă rugăm să contactați un administrator."
}
},
"Dashboard": {
"Announcements": {
"Guest": {
"Title": "Salutare! Sunt bucuros ca ești aici!",
"Message": "În acest moment, răsfoiești aplicația mea că invitat. Reține că nu poți efectua acțiuni, iar majoritatea datelor pe care le vezi sunt false. Scopul aplicației în această stare este de prezentare."
},
"User": {
"Title": "Bine ai revenit, {{userName}}",
"Message": "Aplicația este în continuă dezvoltare, așa că dacă identifici o problemă, te rog să o raportezi. Mulțumesc!"
}
}
},
"User": {
"Profile": {
"Label": "Profil",
"Hello": "Salut, {{userName}}",
"Description": "{{userName}}, autentificat pe {{loginDate}}",
"OpenPortfolio": "Deschide portofoliu",
"Security": {
"UserGroups": "Grupuri utilizator",
"UserRoles": "Roluri utilizator"
}
},
"Settings": "Setări",
"Logout": "Deconectare"
},
"Machine": {
"FullName": "Nume intreg masina",
"Name": "Nume masina",
"IP": "IP",
"MAC": "Adresa MAC",
"Description": "Descriere",
"PoweredOn": "Pornit",
"Actions": {
"Wake": "Pornește",
"Ping": "Ping",
"More": "Mai mult",
"Shutdown": "Oprește",
"Restart": "Repornește",
"Advanced": "Avansat"
}
},
"System": {
"Navigation": {
"MainServices": "Servicii principale",
"Agents": "Agenți"
}
},
"Settings": {
"Navigation": {
"System": "Sistem",
"Appearance": "Aspect",
"Notifications": "Notificări"
},
"Cache": {
"Title": "Setări cache",
"Reset": "Resetați",
"ResetInfo": "Cache resetat."
},
"Appearance": {
"Title": "Setări de aspect"
}
},
"About": {
"Navigation": {
"System": "Sistem",
"ReleaseNotes": "Note de lansare",
"Timeline": "Cronologie"
},
"ReleaseNotes": {
"Version": "Versiune",
"Date": "Dată"
},
"System": {
"Description": {
"Title": "Network resurrector system",
"FirstPhrase": "Totul trebuie să poată fi gestionat de la distanță. Chiar și serverele oprite. Așa a apărut Network resurrector, instrumentul pe care l-am scris special pentru a-mi putea porni mașinile de care nu am nevoie să fie pornite tot timpul.",
"SecondPhrase": "Network Resurrector este un sistem care cuprinde cinci servicii esențiale care permit executarea funcționalității sale de bază. Pentru a activa diverse funcții suplimentare, cum ar fi mecanismul de notificare, componente suplimentare pot fi adăugate la sistem ca opțiune.",
"Frontend": "Frontend: Componenta frontend este o aplicație web scrisă în React JS care are rolul de a oferi utilizatorului o interfață vizuală prietenoasă prin care să interacționeze cu sistemul.",
"Api": "API: Componenta API este un .NET 6 REST API care are rolul de a media schimbul de date și comenzi între componenta frontend și baza de date sau componenta server.",
"Server": "Server: Componenta server este un serviciu .NET 6 specializat în executarea acțiunilor 'WakeOnLAN', 'Ping' și 'Shutdown' pentru mașinile din rețeaua sa.",
"Agent": "Agent: Agentul este un serviciu .NET 6 specializat în executarea acțiunilor 'Shutdown', 'Restart', 'Sleep', 'Logout' și 'Lock' pe mașina gazdă pe care este instalat. Fiecare acțiune poate fi executată în momentul lansării sau cu o anumită întârziere. Dacă o acțiune este solicitată cu întârziere și ulterior utilizatorul se răzgândește, el poate anula acțiunea executând comanda separată de tip 'Cancel'.",
"Tuitio": "Tuitio: Tuitio este serverul meu de identitate personală. Gestionează autentificarea utilizatorilor în cadrul aplicației și autorizează solicitările făcute de aceasta. Mai multe informații despre Tuitio pot fi găsite pe pagina sa dedicată."
},
"Services": {
"Frontend": "Frontend",
"Api": "API",
"Server": "Server",
"Tuitio": "Tuitio"
},
"Version": {
"Server": "Server: {{version}}",
"Api": "API: {{version}}",
"Frontend": "UI: {{version}}",
"LastReleaseDate": "Data ultimei actualizări: {{date}}"
}
}
}
}

BIN
frontend/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

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

29
frontend/setenv.js Normal file
View File

@ -0,0 +1,29 @@
"use strict";
const fs = require("fs");
const path = require("path");
const prefix = "REACT_APP_";
const publicUrl = process.env.PUBLIC_URL || "";
const scriptPath = path.join("./application", publicUrl, "env.js");
function generateScriptContent() {
const prefixRegex = new RegExp(`^${prefix}`);
const env = process.env;
const config = Object.keys(env)
.filter(key => prefixRegex.test(key))
.reduce((c, key) => Object.assign({}, c, { [key]: env[key] }), {});
return `window.env=${JSON.stringify(config)};`;
}
function saveScriptContent(scriptContents) {
fs.writeFile(scriptPath, scriptContents, "utf8", function (err) {
if (err) throw err;
});
}
console.log("Setting environment variables...");
const scriptContent = generateScriptContent();
saveScriptContent(scriptContent);
console.log(
`Updated ${scriptPath} with ${prefix}* environment variables: ${scriptContent}.`
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@ -0,0 +1,15 @@
import React from "react";
import { UserPermissionsProvider, SensitiveInfoProvider } from "../providers";
import AppLayout from "./layout/AppLayout";
const App = () => {
return (
<UserPermissionsProvider>
<SensitiveInfoProvider>
<AppLayout />
</SensitiveInfoProvider>
</UserPermissionsProvider>
);
};
export default App;

View File

@ -0,0 +1,73 @@
import React from "react";
import PropTypes from "prop-types";
import App from "./App";
import { BrowserRouter, Switch, Redirect, Route } from "react-router-dom";
import { useTuitioToken } from "@flare/tuitio-client-react";
import LoginContainer from "../features/login/components/LoginContainer";
const PrivateRoute = ({ component, ...rest }) => {
const { valid } = useTuitioToken();
return (
<Route
{...rest}
render={props =>
valid ? (
React.createElement(component, props)
) : (
<Redirect
to={{
pathname: "/login",
state: {
from: props.location
}
}}
/>
)
}
/>
);
};
PrivateRoute.propTypes = {
component: PropTypes.func.isRequired,
location: PropTypes.object
};
const PublicRoute = ({ component, ...rest }) => {
const { valid } = useTuitioToken();
return (
<Route
{...rest}
render={props =>
valid ? (
<Redirect
to={{
pathname: "/"
}}
/>
) : (
React.createElement(component, props)
)
}
/>
);
};
PublicRoute.propTypes = {
component: PropTypes.func.isRequired
};
const AppRouter = () => {
return (
<BrowserRouter basename={process.env.PUBLIC_URL || ""}>
<Switch>
<Route exact path="/" render={() => <Redirect to="/dashboard" />} />
<PublicRoute path="/login" component={LoginContainer} />
<PrivateRoute path="/" component={App} />
</Switch>
</BrowserRouter>
);
};
export default AppRouter;

View File

@ -0,0 +1,42 @@
import React, { useMemo } from "react";
import PropTypes from "prop-types";
import Typography from "@material-ui/core/Typography";
import { makeStyles } from "@material-ui/core/styles";
const useStyles = makeStyles(theme => ({
panel: {
display: "flex"
},
label: {
marginRight: "4px"
},
data: {
fontWeight: theme.typography.fontWeightMedium
}
}));
const DataLabel = ({ label, data }) => {
const classes = useStyles();
const lbl = useMemo(
() => (label.endsWith(":") ? label : `${label}:`),
[label]
);
return (
<div className={classes.panel}>
<Typography variant="body2" className={classes.label}>
{lbl}
</Typography>
<Typography variant="body2" className={classes.data}>
{data}
</Typography>
</div>
);
};
DataLabel.propTypes = {
label: PropTypes.string.isRequired,
data: PropTypes.string
};
export default DataLabel;

View File

@ -0,0 +1,45 @@
import React, { useState } from "react";
import PropTypes from "prop-types";
import ToggleButton from "@material-ui/lab/ToggleButton";
import ToggleButtonGroup from "@material-ui/lab/ToggleButtonGroup";
import { Tooltip } from "@material-ui/core";
const NavigationButtons = ({ tabs, onTabChange }) => {
const [selected, setSelected] = useState(tabs[0].code);
const handleTabSelection = (_event, tabCode) => {
setSelected(tabCode);
onTabChange && onTabChange(tabCode);
};
return (
<ToggleButtonGroup
size="small"
value={selected}
exclusive
onChange={handleTabSelection}
>
{tabs.map(tab => (
<ToggleButton
key={tab.code}
value={tab.code}
aria-label="navigation buttons"
disabled={selected === tab.code}
>
<Tooltip title={tab.tooltip}>
<tab.icon color="primary" />
</Tooltip>
</ToggleButton>
))}
</ToggleButtonGroup>
);
};
NavigationButtons.propTypes = {
tabs: PropTypes.arrayOf(
PropTypes.shape({ code: PropTypes.string.isRequired })
).isRequired,
onTabChange: PropTypes.func
};
export default NavigationButtons;

View File

@ -0,0 +1,29 @@
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import { Alert, AlertTitle } from "@material-ui/lab";
import { useTranslation } from "react-i18next";
const useStyles = makeStyles(theme => ({
alert: {
width: "100%",
"& > * + *": {
marginTop: theme.spacing(1)
}
}
}));
const NotAllowed = () => {
const classes = useStyles();
const { t } = useTranslation();
return (
<div className={classes.alert}>
<Alert variant="outlined" severity="error">
<AlertTitle>{t("Announcements.NotAllowed.Title")}</AlertTitle>
{t("Announcements.NotAllowed.Message")}
</Alert>
</div>
);
};
export default NotAllowed;

View File

@ -0,0 +1,44 @@
import React from "react";
import PropTypes from "prop-types";
import { makeStyles } from "@material-ui/core/styles";
import { Typography } from "@material-ui/core";
const useStyles = makeStyles(theme => ({
box: {
display: "flex",
justifyContent: "space-between",
marginBottom: theme.spacing(1),
marginTop: theme.spacing(0)
},
title: {
display: "flex",
justifyContent: "center",
flexDirection: "column",
minHeight: "40px"
},
titleText: { textTransform: "uppercase" }
}));
const PageTitle = ({ text, toolBar, navigation }) => {
const classes = useStyles();
return (
<div className={classes.box}>
{navigation && navigation}
<div className={classes.title}>
<Typography className={classes.titleText} variant="h3" size="sm">
{text}
</Typography>
</div>
{toolBar && toolBar}
</div>
);
};
PageTitle.propTypes = {
text: PropTypes.string.isRequired,
toolBar: PropTypes.node,
navigation: PropTypes.node
};
export default PageTitle;

View File

@ -0,0 +1,27 @@
import React from "react";
import PropTypes from "prop-types";
import { Typography } from "@material-ui/core";
import { makeStyles } from "@material-ui/core/styles";
const useStyles = makeStyles(theme => ({
paper: {
margin: theme.spacing(1)
}
}));
const PaperTitle = ({ text }) => {
const classes = useStyles();
return (
<div className={classes.paper}>
<Typography variant="h5" gutterBottom>
{text}
</Typography>
</div>
);
};
PaperTitle.propTypes = {
text: PropTypes.string.isRequired
};
export default PaperTitle;

View File

@ -0,0 +1,4 @@
import DataLabel from "./DataLabel";
import PaperTitle from "./PaperTitle";
export { DataLabel, PaperTitle };

View File

@ -0,0 +1,61 @@
import React, { useState } from "react";
import PropTypes from "prop-types";
import {
InputAdornment,
TextField,
makeStyles,
IconButton
} from "@material-ui/core";
import { Visibility, VisibilityOff, LockOutlined } from "@material-ui/icons";
const useStyles = makeStyles(theme => ({
margin: {
margin: theme.spacing(1)
}
}));
const PasswordField = ({ label, ...rest }) => {
const [showPassword, setShowPassword] = useState(false);
const classes = useStyles();
const handleClickShowPassword = () => {
setShowPassword(!showPassword);
};
const handleMouseDownPassword = event => {
event.preventDefault();
};
return (
<TextField
className={classes.margin}
label={label || "Password"}
{...rest}
type={showPassword ? "text" : "password"}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<LockOutlined />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={handleClickShowPassword}
onMouseDown={handleMouseDownPassword}
>
{showPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
)
}}
/>
);
};
PasswordField.propTypes = {
label: PropTypes.string
};
export default PasswordField;

View File

@ -0,0 +1,34 @@
import React, { useState } from "react";
import { makeStyles } from "@material-ui/core/styles";
import AppRoutes from "./AppRoutes";
import TopBar from "./TopBar";
import Sidebar from "./Sidebar";
import styles from "./styles";
const useStyles = makeStyles(styles);
const AppLayout = () => {
const [open, setOpen] = useState(false);
const classes = useStyles();
const handleDrawerOpen = () => {
setOpen(true);
};
const handleDrawerClose = () => {
setOpen(false);
};
return (
<div className={classes.root}>
<TopBar open={open} handleDrawerOpen={handleDrawerOpen} />
<Sidebar open={open} handleDrawerClose={handleDrawerClose} />
<main className={classes.content}>
<div className={classes.toolbar} />
<AppRoutes />
</main>
</div>
);
};
export default AppLayout;

View File

@ -0,0 +1,25 @@
import React from "react";
import { Route, Switch } from "react-router-dom";
import PageNotFound from "./PageNotFound";
import NetworkContainer from "../../features/network/components/NetworkContainer";
import SystemContainer from "../../features/system/SystemContainer";
import SettingsContainer from "../../features/settings/SettingsContainer";
import DashboardContainer from "../../features/dashboard/DashboardContainer";
import UserProfileContainer from "../../features/user/profile/card/UserProfileContainer";
import AboutContainer from "../../features/about/AboutContainer";
const AppRoutes = () => {
return (
<Switch>
<Route exact path="/dashboard" component={DashboardContainer} />
<Route exact path="/user-profile" component={UserProfileContainer} />
<Route exact path="/machines" component={NetworkContainer} />
<Route exact path="/system" component={SystemContainer} />
<Route exact path="/settings" component={SettingsContainer} />
<Route exact path="/about" component={AboutContainer} />
<Route component={PageNotFound} />
</Switch>
);
};
export default AppRoutes;

View File

@ -0,0 +1,25 @@
import React from "react";
import { IconButton } from "@material-ui/core";
import {
Brightness2 as MoonIcon,
WbSunny as SunIcon
} from "@material-ui/icons";
import { useApplicationTheme } from "../../providers/ThemeProvider";
const LightDarkToggle = () => {
const { isDark, onDarkModeChanged } = useApplicationTheme();
const handleChange = () => onDarkModeChanged(!isDark);
return (
<IconButton
aria-label="light-dark-toggle"
color="inherit"
onClick={handleChange}
>
{isDark ? <SunIcon /> : <MoonIcon />}
</IconButton>
);
};
export default LightDarkToggle;

View File

@ -0,0 +1,5 @@
import React from "react";
const PageNotFound = () => <h1>Oops! Page not found</h1>;
export default PageNotFound;

View File

@ -0,0 +1,103 @@
import React, { useState } from "react";
import {
IconButton,
Menu,
MenuItem,
Typography,
ListItemIcon
} from "@material-ui/core";
import AccountCircle from "@material-ui/icons/AccountCircle";
import ExitToAppIcon from "@material-ui/icons/ExitToApp";
import AccountBoxIcon from "@material-ui/icons/AccountBox";
import SettingsIcon from "@material-ui/icons/Settings";
import { useHistory } from "react-router-dom";
import { useTuitioClient } from "@flare/tuitio-client-react";
import { useToast } from "../../hooks";
import styles from "./styles";
import { makeStyles } from "@material-ui/core/styles";
import { useTranslation } from "react-i18next";
const useStyles = makeStyles(styles);
const ProfileButton = () => {
const history = useHistory();
const { error } = useToast();
const classes = useStyles();
const { t } = useTranslation();
const { logout } = useTuitioClient({
onLogoutFailed: errorMessage => error(errorMessage),
onLogoutError: err => error(err.message)
});
const [anchorEl, setAnchorEl] = useState(null);
const openUserMenu = Boolean(anchorEl);
const handleMenu = event => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<div>
<IconButton
aria-label="account of current user"
aria-controls="menu-appbar"
aria-haspopup="true"
onClick={handleMenu}
color="inherit"
>
<AccountCircle />
</IconButton>
<Menu
id="menu-appbar"
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "right"
}}
keepMounted
transformOrigin={{
vertical: "top",
horizontal: "right"
}}
open={openUserMenu}
onClose={handleClose}
>
<MenuItem
onClick={() => {
history.push("/user-profile");
handleClose();
}}
>
<ListItemIcon className={classes.menuItemIcon}>
<AccountBoxIcon fontSize="small" />
</ListItemIcon>
<Typography variant="inherit">{t("User.Profile.Label")}</Typography>
</MenuItem>
<MenuItem
onClick={() => {
history.push("/settings");
handleClose();
}}
>
<ListItemIcon className={classes.menuItemIcon}>
<SettingsIcon fontSize="small" />
</ListItemIcon>
<Typography variant="inherit">{t("User.Settings")}</Typography>
</MenuItem>
<MenuItem onClick={logout}>
<ListItemIcon className={classes.menuItemIcon}>
<ExitToAppIcon fontSize="small" />
</ListItemIcon>
<Typography variant="inherit">{t("User.Logout")}</Typography>
</MenuItem>
</Menu>
</div>
);
};
export default ProfileButton;

View File

@ -0,0 +1,25 @@
import React from "react";
import { IconButton } from "@material-ui/core";
import {
Visibility as VisibilityIcon,
VisibilityOff as VisibilityOffIcon
} from "@material-ui/icons";
import { useSensitiveInfo } from "../../hooks";
const SensitiveInfoToggle = () => {
const { enabled, onSensitiveInfoEnabled } = useSensitiveInfo();
const handleChange = () => onSensitiveInfoEnabled(!enabled);
return (
<IconButton
aria-label="sensitive-info-toggle"
color="inherit"
onClick={handleChange}
>
{enabled ? <VisibilityOffIcon /> : <VisibilityIcon />}
</IconButton>
);
};
export default SensitiveInfoToggle;

View File

@ -0,0 +1,162 @@
import React, { useState } from "react";
import PropTypes from "prop-types";
import clsx from "clsx";
import { makeStyles, useTheme } from "@material-ui/core/styles";
import {
Drawer,
List,
Divider,
IconButton,
ListItemIcon,
ListItemText
} from "@material-ui/core";
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
import ChevronRightIcon from "@material-ui/icons/ChevronRight";
import ListItem from "@material-ui/core/ListItem";
import BuildIcon from "@material-ui/icons/Build";
import DnsIcon from "@material-ui/icons/Dns";
import DeviceHubIcon from "@material-ui/icons/DeviceHub";
import SettingsIcon from "@material-ui/icons/Settings";
import DashboardIcon from "@material-ui/icons/Dashboard";
import FeaturedPlayListIcon from "@material-ui/icons/FeaturedPlayList";
import { useHistory } from "react-router-dom";
import { useTranslation } from "react-i18next";
import styles from "./styles";
const useStyles = makeStyles(styles);
const menu = [
{
order: 0,
items: [
{
code: "dashboard",
name: "Menu.Dashboard",
route: "/dashboard",
icon: <DashboardIcon />,
order: 0
},
{
code: "machines",
name: "Menu.Machines",
route: "/machines",
icon: <DnsIcon />,
order: 1
},
{
code: "system",
name: "Menu.System",
route: "/system",
icon: <DeviceHubIcon />,
order: 2
}
]
},
{
order: 1,
items: [
{
code: "administration",
name: "Menu.Administration",
route: "/administration",
icon: <BuildIcon />,
order: 0
},
{
code: "settings",
name: "Menu.Settings",
route: "/settings",
icon: <SettingsIcon />,
order: 1
}
]
},
{
order: 2,
items: [
{
code: "about",
name: "Menu.About",
route: "/about",
icon: <FeaturedPlayListIcon />,
order: 0
}
]
}
];
const sortedMenu = menu.sort((i1, i2) => i1 - i2);
const Sidebar = ({ open, handleDrawerClose }) => {
const [selected, setSelected] = useState(null);
const classes = useStyles();
const theme = useTheme();
const history = useHistory();
const { t } = useTranslation();
const handleClick = route => () => {
setSelected(route);
history.push(route);
};
const isSelected = key => selected === key;
return (
<Drawer
variant="permanent"
className={clsx(classes.drawer, {
[classes.drawerOpen]: open,
[classes.drawerClose]: !open
})}
classes={{
paper: clsx({
[classes.drawerOpen]: open,
[classes.drawerClose]: !open
})
}}
>
<div className={classes.toolbar}>
<IconButton onClick={handleDrawerClose}>
{theme.direction === "rtl" ? (
<ChevronRightIcon />
) : (
<ChevronLeftIcon />
)}
</IconButton>
</div>
<Divider />
{sortedMenu.map((menu, index) => {
const isLast = index === sortedMenu.length - 1;
return (
<React.Fragment key={`menu-${menu.order}`}>
<List>
{menu.items
.sort((i1, i2) => i1 - i2)
.map(item => (
<ListItem
button
key={item.code}
onClick={handleClick(item.route)}
selected={isSelected(item.route)}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={t(item.name)} />
</ListItem>
))}
</List>
{!isLast && <Divider />}
</React.Fragment>
);
})}
</Drawer>
);
};
Sidebar.propTypes = {
open: PropTypes.bool.isRequired,
handleDrawerClose: PropTypes.func.isRequired
};
export default Sidebar;

View File

@ -0,0 +1,52 @@
import React from "react";
import PropTypes from "prop-types";
import clsx from "clsx";
import { makeStyles } from "@material-ui/core/styles";
import { AppBar, Toolbar, Typography, IconButton } from "@material-ui/core";
import MenuIcon from "@material-ui/icons/Menu";
import ProfileButton from "./ProfileButton";
import LightDarkToggle from "./LightDarkToggle";
import SensitiveInfoToggle from "./SensitiveInfoToggle";
import styles from "./styles";
const useStyles = makeStyles(styles);
const TopBar = ({ open, handleDrawerOpen }) => {
const classes = useStyles();
return (
<AppBar
position="fixed"
className={clsx(classes.appBar, {
[classes.appBarShift]: open
})}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
onClick={handleDrawerOpen}
edge="start"
className={clsx(classes.menuButton, {
[classes.hide]: open
})}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap className={classes.title}>
Network resurrector
</Typography>
<SensitiveInfoToggle />
<LightDarkToggle />
<ProfileButton />
</Toolbar>
</AppBar>
);
};
TopBar.propTypes = {
open: PropTypes.bool.isRequired,
handleDrawerOpen: PropTypes.func.isRequired
};
export default TopBar;

View File

@ -0,0 +1,71 @@
const drawerWidth = 240;
const styles = theme => ({
root: {
display: "flex"
},
appBar: {
zIndex: theme.zIndex.drawer + 1,
transition: theme.transitions.create(["width", "margin"], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen
})
},
appBarShift: {
marginLeft: drawerWidth,
width: `calc(100% - ${drawerWidth}px)`,
transition: theme.transitions.create(["width", "margin"], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen
})
},
menuButton: {
marginRight: 36
},
hide: {
display: "none"
},
drawer: {
width: drawerWidth,
flexShrink: 0,
whiteSpace: "nowrap"
},
drawerOpen: {
width: drawerWidth,
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen
})
},
drawerClose: {
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen
}),
overflowX: "hidden",
width: theme.spacing(7) + 1,
[theme.breakpoints.up("sm")]: {
width: theme.spacing(9) + 1
}
},
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
padding: theme.spacing(0, 1),
// necessary for content to be below app bar
...theme.mixins.toolbar
},
title: {
flexGrow: 1
},
content: {
flexGrow: 1,
padding: theme.spacing(2)
},
menuItemIcon: {
minWidth: "26px"
}
});
export default styles;

View File

@ -0,0 +1,58 @@
import React, { useState, useMemo } from "react";
import PageTitle from "../../components/common/PageTitle";
import BubbleChartIcon from "@material-ui/icons/BubbleChart";
import NotesIcon from "@material-ui/icons/Notes";
import TimelineIcon from "@material-ui/icons/Timeline";
import { useTranslation } from "react-i18next";
import AboutSystemContainer from "./system/AboutSystemContainer";
import ReleaseNotesContainer from "./releaseNotes/ReleaseNotesContainer";
import NavigationButtons from "../../components/common/NavigationButtons";
const NavigationTabs = {
SYSTEM: "About.Navigation.System",
RELEASE_NOTES: "About.Navigation.ReleaseNotes",
TIMELINE: "About.Navigation.Timeline"
};
const tabs = [
{
code: NavigationTabs.SYSTEM,
icon: BubbleChartIcon
},
{
code: NavigationTabs.RELEASE_NOTES,
icon: NotesIcon
},
{
code: NavigationTabs.TIMELINE,
icon: TimelineIcon
}
];
const AboutContainer = () => {
const [tab, setTab] = useState(NavigationTabs.SYSTEM);
const { t } = useTranslation();
const navigationTabs = useMemo(
() => tabs.map(z => ({ ...z, tooltip: t(z.code) })),
[t]
);
return (
<>
<PageTitle
text={t(tab)}
navigation={
<NavigationButtons tabs={navigationTabs} onTabChange={setTab} />
}
/>
{tab === NavigationTabs.SYSTEM && <AboutSystemContainer />}
{tab === NavigationTabs.RELEASE_NOTES && <ReleaseNotesContainer />}
{tab === NavigationTabs.TIMELINE && (
<ReleaseNotesContainer view="timeline" />
)}
</>
);
};
export default AboutContainer;

View File

@ -0,0 +1,27 @@
import React from "react";
import PropTypes from "prop-types";
import Typography from "@material-ui/core/Typography";
const ReleaseNote = ({ releaseNote }) => {
return (
<div>
{releaseNote.notes.map(note => {
return (
<Typography
key={releaseNote.notes.indexOf(note)}
variant="body2"
gutterBottom
>
{note}
</Typography>
);
})}
</div>
);
};
ReleaseNote.propTypes = {
releaseNote: PropTypes.object.isRequired
};
export default ReleaseNote;

View File

@ -0,0 +1,37 @@
import React from "react";
import PropTypes from "prop-types";
import { Grid, Typography } from "@material-ui/core";
import { useTranslation } from "react-i18next";
const ReleaseNoteSummary = ({ releaseNote, collapsed }) => {
const { t } = useTranslation();
return (
<Grid container>
<Grid item xs={6} sm={2} md={2}>
<Typography variant={collapsed ? "subtitle2" : "h6"}>
{`${t("About.ReleaseNotes.Version")}: ${releaseNote.version}`}
</Typography>
</Grid>
<Grid item xs={6} sm={2} md={collapsed ? 2 : 4}>
<Typography variant={collapsed ? "subtitle2" : "h6"}>
{`${t("About.ReleaseNotes.Date")}: ${t("DATE_FORMAT", {
date: { value: releaseNote.date, format: "DD-MM-YYYY HH:mm" }
})}`}
</Typography>
</Grid>
{collapsed && (
<Grid item xs={12} sm={8} md={8}>
<Typography variant="body2">{releaseNote.notes[0]}</Typography>
</Grid>
)}
</Grid>
);
};
ReleaseNoteSummary.propTypes = {
releaseNote: PropTypes.object.isRequired,
collapsed: PropTypes.bool.isRequired
};
export default ReleaseNoteSummary;

View File

@ -0,0 +1,36 @@
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import ReleaseNotesList from "./ReleaseNotesList";
import TimelineComponent from "../timeline/TimelineComponent";
import { routes, get } from "../../../utils/api";
const sort = releases =>
releases.sort((a, b) => new Date(b.date) - new Date(a.date));
const ReleaseNotesContainer = ({ view }) => {
const [state, setState] = useState({ data: [], loaded: false });
useEffect(() => {
if (state.loaded) return;
get(routes.releaseNotes, {
onCompleted: data => setState({ data, loaded: true })
});
}, [state.loaded]);
return (
<>
{state.loaded &&
(view === "timeline" ? (
<TimelineComponent releases={sort(state.data)} />
) : (
<ReleaseNotesList releases={sort(state.data)} />
))}
</>
);
};
ReleaseNotesContainer.propTypes = {
view: PropTypes.string
};
export default ReleaseNotesContainer;

View File

@ -0,0 +1,59 @@
import React, { useState } from "react";
import PropTypes from "prop-types";
import {
Accordion,
AccordionSummary,
AccordionDetails
} from "@material-ui/core";
import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
import ReleaseNoteSummary from "./ReleaseNoteSummary";
import ReleaseNote from "./ReleaseNote";
const ReleaseNotesList = ({ releases }) => {
const [flags, setFlags] = useState({});
const handleToggle = key => (_, expanded) => {
setFlags(prev => ({
...prev,
[key]: expanded
}));
};
const isCollapsed = key => {
const expanded = flags[key];
const collapsed = !expanded || expanded === false;
return collapsed;
};
return (
<>
{releases.map(release => {
return (
<Accordion
key={release.version}
onChange={handleToggle(release.version)}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
id={`panel-${release.version}-header`}
>
<ReleaseNoteSummary
releaseNote={release}
collapsed={isCollapsed(release.version)}
/>
</AccordionSummary>
<AccordionDetails>
<ReleaseNote releaseNote={release} />
</AccordionDetails>
</Accordion>
);
})}
</>
);
};
ReleaseNotesList.propTypes = {
releases: PropTypes.array.isRequired
};
export default ReleaseNotesList;

View File

@ -0,0 +1,103 @@
import React from "react";
import PropTypes from "prop-types";
import { makeStyles } from "@material-ui/core/styles";
import Card from "@material-ui/core/Card";
import CardActions from "@material-ui/core/CardActions";
import CardContent from "@material-ui/core/CardContent";
import Button from "@material-ui/core/Button";
import Typography from "@material-ui/core/Typography";
import { useTranslation } from "react-i18next";
import OpenInNewIcon from "@material-ui/icons/OpenInNew";
const useStyles = makeStyles(theme => ({
bullet: {
display: "inline-block",
margin: "0 4px",
transform: "scale(1.5)"
},
service: {
marginTop: theme.spacing(1)
}
}));
const buttons = [
{
code: "About.System.Services.Frontend",
url: "https://lab.code-rove.com/gitea/tudor.stanciu/network-resurrector-frontend"
},
{
code: "About.System.Services.Api",
url: "https://lab.code-rove.com/gitea/tudor.stanciu/network-resurrector"
},
{
code: "About.System.Services.Server",
url: "https://lab.code-rove.com/gitea/tudor.stanciu/network-resurrector"
},
{
code: "About.System.Services.Tuitio",
url: "https://lab.code-rove.com/gitea/tudor.stanciu/tuitio"
}
];
const AboutSystemComponent = ({ handleOpenInNewTab }) => {
const classes = useStyles();
const { t } = useTranslation();
const bullet = <span className={classes.bullet}></span>;
return (
<Card variant="outlined">
<CardContent>
<Typography variant="h5" gutterBottom>
{t("About.System.Description.Title")}
</Typography>
<Typography color="textSecondary">
{t("About.System.Description.FirstPhrase")}
</Typography>
<Typography color="textSecondary">
{t("About.System.Description.SecondPhrase")}
</Typography>
<Typography className={classes.service} color="textSecondary">
{bullet}
{t("About.System.Description.Frontend")}
</Typography>
<Typography className={classes.service} color="textSecondary">
{bullet}
{t("About.System.Description.Api")}
</Typography>
<Typography className={classes.service} color="textSecondary">
{bullet}
{t("About.System.Description.Server")}
</Typography>
<Typography className={classes.service} color="textSecondary">
{bullet}
{t("About.System.Description.Agent")}
</Typography>
<Typography className={classes.service} color="textSecondary">
{bullet}
{t("About.System.Description.Tuitio")}
</Typography>
</CardContent>
<CardActions>
{buttons.map(button => (
<Button
key={button.code}
size="small"
color="primary"
startIcon={<OpenInNewIcon />}
onClick={handleOpenInNewTab(button.url)}
>
{t(button.code)}
</Button>
))}
</CardActions>
</Card>
);
};
AboutSystemComponent.propTypes = {
handleOpenInNewTab: PropTypes.func.isRequired
};
export default AboutSystemComponent;

View File

@ -0,0 +1,35 @@
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import AboutSystemComponent from "./AboutSystemComponent";
import SystemVersionContainer from "./SystemVersionContainer";
const useStyles = makeStyles(theme => {
return {
page: {
display: "flex",
flexDirection: "column"
},
element: {
marginTop: theme.spacing(1)
}
};
});
const AboutSystemContainer = () => {
const classes = useStyles();
const handleOpenInNewTab = url => event => {
window.open(url, "_blank");
event.preventDefault();
};
return (
<div className={classes.page}>
<AboutSystemComponent handleOpenInNewTab={handleOpenInNewTab} />
<div className={classes.element}>
<SystemVersionContainer />
</div>
</div>
);
};
export default AboutSystemContainer;

View File

@ -0,0 +1,154 @@
import React, { useMemo, useEffect, useState } from "react";
import PropTypes from "prop-types";
import { makeStyles } from "@material-ui/core/styles";
import {
List,
ListItem,
ListItemText,
ListItemAvatar
} from "@material-ui/core";
import Avatar from "@material-ui/core/Avatar";
import WebAssetIcon from "@material-ui/icons/WebAsset";
import DeveloperBoardIcon from "@material-ui/icons/DeveloperBoard";
import SettingsInputSvideoIcon from "@material-ui/icons/SettingsInputSvideo";
import { useTranslation } from "react-i18next";
import packageData from "../../../../package.json";
import Paper from "@material-ui/core/Paper";
const useStyles = makeStyles(theme => {
return {
horizontally: {
display: "flex",
flexDirection: "row",
padding: 0
},
vertical: {
width: "100%"
},
value: {
fontSize: "0.9rem",
fontWeight: theme.typography.fontWeightMedium
},
versionAvatar: {
backgroundColor: theme.palette.secondary.main
}
};
});
const SystemVersionComponent = ({ data }) => {
const classes = useStyles();
const [listClass, setListClass] = useState(classes.horizontally);
const { t } = useTranslation();
useEffect(() => {
const mediaQuery = window.matchMedia("(max-width: 800px)");
function handleMatches(event) {
const cssClass = event.matches ? classes.vertical : classes.horizontally;
setListClass(cssClass);
}
handleMatches(mediaQuery);
mediaQuery.addListener(handleMatches);
return () => {
mediaQuery.removeListener(handleMatches);
};
}, [classes.horizontally, classes.vertical]);
const lastReleaseDate = useMemo(() => {
const format = "DD-MM-YYYY HH:mm:ss";
const server = t("DATE_FORMAT", {
date: {
value: data.server.lastReleaseDate,
format
}
});
const api = t("DATE_FORMAT", {
date: {
value: data.api.lastReleaseDate,
format
}
});
const frontend = t("DATE_FORMAT", {
date: {
value: process.env.APP_DATE ?? new Date(),
format
}
});
return { server, api, frontend };
}, [data, t]);
return (
<Paper variant="outlined">
<List className={listClass}>
<ListItem>
<ListItemAvatar>
<Avatar className={classes.versionAvatar}>
<DeveloperBoardIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<span className={classes.value}>
{t("About.System.Version.Server", {
version: data.server.version
})}
</span>
}
secondary={t("About.System.Version.LastReleaseDate", {
date: lastReleaseDate.server
})}
/>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar className={classes.versionAvatar}>
<SettingsInputSvideoIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<span className={classes.value}>
{t("About.System.Version.Api", {
version: data.api.version
})}
</span>
}
secondary={t("About.System.Version.LastReleaseDate", {
date: lastReleaseDate.api
})}
/>
</ListItem>
<ListItem>
<ListItemAvatar>
<Avatar className={classes.versionAvatar}>
<WebAssetIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<span className={classes.value}>
{t("About.System.Version.Frontend", {
version: process.env.APP_VERSION ?? packageData.version
})}
</span>
}
secondary={t("About.System.Version.LastReleaseDate", {
date: lastReleaseDate.frontend
})}
/>
</ListItem>
</List>
</Paper>
);
};
SystemVersionComponent.propTypes = {
data: PropTypes.object.isRequired
};
export default SystemVersionComponent;

View File

@ -0,0 +1,18 @@
import React, { useState, useEffect } from "react";
import SystemVersionComponent from "./SystemVersionComponent";
import { routes, get } from "../../../utils/api";
const SystemVersionContainer = () => {
const [state, setState] = useState({ data: {}, loaded: false });
useEffect(() => {
if (state.loaded) return;
get(routes.systemVersion, {
onCompleted: data => setState({ data, loaded: true })
});
}, [state.loaded]);
return <>{state.loaded && <SystemVersionComponent data={state.data} />}</>;
};
export default SystemVersionContainer;

View File

@ -0,0 +1,117 @@
import React from "react";
import PropTypes from "prop-types";
import { makeStyles } from "@material-ui/core/styles";
import Timeline from "@material-ui/lab/Timeline";
import TimelineItem from "@material-ui/lab/TimelineItem";
import TimelineSeparator from "@material-ui/lab/TimelineSeparator";
import TimelineConnector from "@material-ui/lab/TimelineConnector";
import TimelineContent from "@material-ui/lab/TimelineContent";
import TimelineOppositeContent from "@material-ui/lab/TimelineOppositeContent";
import TimelineDot from "@material-ui/lab/TimelineDot";
import Paper from "@material-ui/core/Paper";
import Typography from "@material-ui/core/Typography";
import { useTranslation } from "react-i18next";
import { getRandomElement } from "../../../utils";
import {
Announcement,
AmpStories,
Apps,
BugReport,
DeviceHub,
Equalizer,
FilterTiltShift,
Grain,
Layers,
LocalOffer,
Memory,
NetworkCheck,
OfflineBolt,
Star,
Whatshot,
Widgets
} from "@material-ui/icons";
const timelineIcons = [
Announcement,
AmpStories,
Apps,
BugReport,
DeviceHub,
Equalizer,
FilterTiltShift,
Grain,
Layers,
LocalOffer,
Memory,
NetworkCheck,
OfflineBolt,
Star,
Whatshot,
Widgets
];
const timelineDotVariants = [
{ color: "primary", variant: "outlined" },
{ color: "secondary", variant: "outlined" }
];
const useStyles = makeStyles(() => ({
paper: {
padding: "6px 16px"
}
}));
const TimelineComponent = ({ releases }) => {
const classes = useStyles();
const { t } = useTranslation();
const _releases = releases.map((release, index) => {
const isLast = index === releases.length - 1;
const icon = getRandomElement(timelineIcons);
const dot = getRandomElement(timelineDotVariants);
return { ...release, isLast, icon, dot };
});
return (
<Timeline align="alternate">
{_releases.map(release => (
<TimelineItem key={release.version}>
<TimelineOppositeContent>
<Typography variant="body2" color="textSecondary">
{t("DATE_FORMAT", {
date: { value: release.date, format: "DD-MM-YYYY HH:mm" }
})}
</Typography>
</TimelineOppositeContent>
<TimelineSeparator>
<TimelineDot
color={release.dot.color}
variant={release.dot.variant}
>
<release.icon />
</TimelineDot>
{!release.isLast && <TimelineConnector />}
</TimelineSeparator>
<TimelineContent>
<Paper elevation={3} className={classes.paper}>
<Typography variant="h6" component="h1">
{release.notes[0]}
</Typography>
{release.notes.slice(1).map((note, index) => (
<Typography key={index} variant="body2">
{note}
</Typography>
))}
</Paper>
</TimelineContent>
</TimelineItem>
))}
</Timeline>
);
};
TimelineComponent.propTypes = {
releases: PropTypes.array.isRequired
};
export default TimelineComponent;

View File

@ -0,0 +1,10 @@
import React from "react";
import AnnouncementsSection from "./announcements/AnnouncementsSection";
const DashboardContainer = () => (
<>
<AnnouncementsSection />
</>
);
export default DashboardContainer;

View File

@ -0,0 +1,17 @@
import React from "react";
import GuestAnnouncement from "./GuestAnnouncement";
import UserAnnouncement from "./UserAnnouncement";
import { usePermissions } from "../../../hooks";
const AnnouncementsSection = () => {
const { loading, isGuest, isUser } = usePermissions();
if (loading) return "";
return (
<>
{isUser && <UserAnnouncement />}
{isGuest && <GuestAnnouncement />}
</>
);
};
export default AnnouncementsSection;

View File

@ -0,0 +1,21 @@
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import { Alert, AlertTitle } from "@material-ui/lab";
import styles from "../styles";
import { useTranslation } from "react-i18next";
const useStyles = makeStyles(styles);
export default function GuestAnnouncement() {
const classes = useStyles();
const { t } = useTranslation();
return (
<div className={classes.alert}>
<Alert variant="outlined" severity="warning">
<AlertTitle>{t("Dashboard.Announcements.Guest.Title")}</AlertTitle>
{t("Dashboard.Announcements.Guest.Message")}
</Alert>
</div>
);
}

View File

@ -0,0 +1,27 @@
import React from "react";
import { makeStyles } from "@material-ui/core/styles";
import { Alert, AlertTitle } from "@material-ui/lab";
import styles from "../styles";
import { useTranslation } from "react-i18next";
import { useTuitioUser } from "@flare/tuitio-client-react";
const useStyles = makeStyles(styles);
export default function UserAnnouncement() {
const classes = useStyles();
const { t } = useTranslation();
const { userName } = useTuitioUser();
return (
<div className={classes.alert}>
<Alert variant="outlined" severity="info">
<AlertTitle>
{t("Dashboard.Announcements.User.Title", {
userName
})}
</AlertTitle>
{t("Dashboard.Announcements.User.Message")}
</Alert>
</div>
);
}

View File

@ -0,0 +1,10 @@
const styles = theme => ({
alert: {
width: "100%",
"& > * + *": {
marginTop: theme.spacing(1)
}
}
});
export default styles;

View File

@ -0,0 +1,32 @@
import React from "react";
import PropTypes from "prop-types";
import { Card } from "@material-ui/core";
import { makeStyles } from "@material-ui/core/styles";
import styles from "../styles";
import LoginComponent from "./LoginComponent";
const useStyles = makeStyles(styles);
const LoginCard = ({ credentials, onChange, onLogin }) => {
const classes = useStyles();
return (
<div className={classes.appLogin}>
<Card variant="outlined">
<LoginComponent
credentials={credentials}
onChange={onChange}
onLogin={onLogin}
/>
</Card>
</div>
);
};
LoginCard.propTypes = {
credentials: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
onLogin: PropTypes.func.isRequired
};
export default LoginCard;

View File

@ -0,0 +1,71 @@
import React from "react";
import PropTypes from "prop-types";
import { makeStyles } from "@material-ui/core/styles";
import {
TextField,
InputAdornment,
Button,
CardActions,
CardContent
} from "@material-ui/core";
import { AccountCircleOutlined } from "@material-ui/icons";
import PasswordField from "../../../components/common/inputs/PasswordField";
import { useTranslation } from "react-i18next";
import styles from "../styles";
const useStyles = makeStyles(styles);
const LoginComponent = ({ credentials, onChange, onLogin }) => {
const classes = useStyles();
const { t } = useTranslation();
return (
<>
<CardContent>
<TextField
className={classes.field}
id="username"
label={t("Login.Username")}
onChange={onChange("userName")}
value={credentials.userName}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<AccountCircleOutlined />
</InputAdornment>
)
}}
/>
<br />
<PasswordField
id="password"
label={t("Login.Password")}
className={classes.field}
onChange={onChange("password")}
value={credentials.password}
onKeyDown={e => {
if (e.key === "Enter") onLogin();
}}
/>
</CardContent>
<CardActions className={classes.actions}>
<Button
className={classes.onRight}
variant="contained"
color="primary"
onClick={onLogin}
>
{t("Login.Label")}
</Button>
</CardActions>
</>
);
};
LoginComponent.propTypes = {
credentials: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
onLogin: PropTypes.func.isRequired
};
export default LoginComponent;

View File

@ -0,0 +1,38 @@
import React, { useState } from "react";
import LoginCard from "./LoginCard";
import { useToast } from "../../../hooks";
import { useTranslation } from "react-i18next";
import { useTuitioClient } from "@flare/tuitio-client-react";
const LoginContainer = () => {
const [credentials, setCredentials] = useState({
userName: "",
password: ""
});
const { error } = useToast();
const { t } = useTranslation();
const { login } = useTuitioClient({
onLoginFailed: () => error(t("Login.IncorrectCredentials")),
onLoginError: err => error(err.message)
});
const handleChange = prop => event => {
setCredentials(prev => ({ ...prev, [prop]: event.target.value }));
};
const handleLogin = () => {
const { userName, password } = credentials;
return login(userName, password);
};
return (
<LoginCard
credentials={credentials}
onChange={handleChange}
onLogin={handleLogin}
/>
);
};
export default LoginContainer;

View File

@ -0,0 +1,21 @@
const styles = theme => ({
onRight: {
marginLeft: "auto"
},
appLogin: {
minHeight: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center"
},
field: {
margin: theme.spacing(1),
width: "300px"
},
actions: {
paddingRight: "16px",
paddingLeft: "16px"
}
});
export default styles;

View File

@ -0,0 +1,114 @@
import React from "react";
import PropTypes from "prop-types";
import {
Accordion,
AccordionSummary,
AccordionDetails,
Grid
} from "@material-ui/core";
import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
import withStyles from "@material-ui/core/styles/withStyles";
import MachineCollapsedContent from "./common/MachineCollapsedContent";
import { DataLabel } from "../../../components/common";
import { useTranslation } from "react-i18next";
import { useSensitiveInfo } from "../../../hooks";
import ActionsGroup from "./common/ActionsGroup";
import { makeStyles } from "@material-ui/core/styles";
const useStyles = makeStyles(() => ({
panel: {
justifyContent: "center",
alignItems: "center"
}
}));
const IconLeftAccordionSummary = withStyles(theme => ({
root: {
minHeight: "20px",
height: "42px",
[theme.breakpoints.down("md")]: {
height: "62px"
},
[theme.breakpoints.down("sm")]: {
height: "102px"
}
},
expandIcon: {
order: -1
}
}))(AccordionSummary);
const GridCell = ({ label, value }) => {
const { mask } = useSensitiveInfo();
return (
<Grid item xs={12} md={6} lg={3}>
<DataLabel label={label} data={mask(value)} />
</Grid>
);
};
GridCell.propTypes = {
label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired
};
const MachineAccordion = ({ machine, actions, logs, addLog }) => {
const { t } = useTranslation();
const classes = useStyles();
return (
<Accordion>
<IconLeftAccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-label="Expand"
aria-controls="additional-actions1-content"
id="additional-actions1-header"
IconButtonProps={{ edge: "start" }}
>
<Grid container className={classes.panel}>
<Grid item xs={11}>
<Grid container>
<GridCell
label={t("Machine.FullName")}
value={machine.fullMachineName}
/>
<GridCell label={t("Machine.Name")} value={machine.machineName} />
<GridCell label={t("Machine.IP")} value={machine.iPv4Address} />
<GridCell label={t("Machine.MAC")} value={machine.macAddress} />
</Grid>
</Grid>
<Grid item xs={1} style={{ textAlign: "right" }}>
<ActionsGroup
className={classes.actions}
machine={machine}
actions={actions}
addLog={addLog}
/>
</Grid>
</Grid>
</IconLeftAccordionSummary>
<AccordionDetails>
<MachineCollapsedContent
description={machine.description}
logs={logs}
style={{ width: "100%" }}
/>
</AccordionDetails>
</Accordion>
);
};
MachineAccordion.propTypes = {
machine: PropTypes.shape({
machineId: PropTypes.number.isRequired,
machineName: PropTypes.string.isRequired,
fullMachineName: PropTypes.string.isRequired,
macAddress: PropTypes.string.isRequired,
iPv4Address: PropTypes.string,
description: PropTypes.string
}).isRequired,
actions: PropTypes.array.isRequired,
logs: PropTypes.array.isRequired,
addLog: PropTypes.func.isRequired
};
export default MachineAccordion;

View File

@ -0,0 +1,133 @@
import React, { useState, useCallback } from "react";
import PropTypes from "prop-types";
import MachineTableRow from "./MachineTableRow";
import MachineAccordion from "./MachineAccordion";
import { ViewModes } from "./ViewModeSelection";
import { useToast } from "../../../hooks";
import { LastPage, RotateLeft, Launch, Stop } from "@material-ui/icons";
import { useTranslation } from "react-i18next";
import { routes, post } from "../../../utils/api";
const MachineContainer = ({ machine, viewMode }) => {
const [logs, setLogs] = useState([]);
const { success, error } = useToast();
const { t } = useTranslation();
const addLog = useCallback(
text => {
setLogs(prev => [...prev, text]);
},
[setLogs]
);
const manageActionResponse = useCallback(
response => {
addLog(`Success: ${response.success}. Status: ${response.status}`);
if (response.success) {
success(response.status);
} else {
error(response.status);
}
},
[error, success, addLog]
);
const pingMachine = useCallback(
async machine => {
await post(
routes.pingMachine,
{ machineId: machine.machineId },
{
onCompleted: manageActionResponse
}
);
},
[manageActionResponse]
);
const shutdownMachine = useCallback(
async machine => {
await post(
routes.shutdownMachine,
{ machineId: machine.machineId, delay: 0, force: false },
{
onCompleted: manageActionResponse
}
);
},
[manageActionResponse]
);
const restartMachine = useCallback(
async machine => {
await post(
routes.restartMachine,
{ machineId: machine.machineId, delay: 0, force: false },
{
onCompleted: manageActionResponse
}
);
},
[manageActionResponse]
);
const actions = [
{
code: "ping",
effect: pingMachine,
icon: LastPage,
tooltip: t("Machine.Actions.Ping"),
main: true
},
{
code: "shutdown",
effect: shutdownMachine,
icon: Stop,
tooltip: t("Machine.Actions.Shutdown"),
main: false
},
{
code: "restart",
effect: restartMachine,
icon: RotateLeft,
tooltip: t("Machine.Actions.Restart"),
main: false
},
{
code: "advanced",
effect: () => {},
icon: Launch,
tooltip: t("Machine.Actions.Advanced"),
main: false
}
];
return (
<>
{viewMode === ViewModes.TABLE && (
<MachineTableRow
machine={machine}
actions={actions}
logs={logs}
addLog={addLog}
/>
)}
{viewMode === ViewModes.ACCORDION && (
<MachineAccordion
machine={machine}
actions={actions}
logs={logs}
addLog={addLog}
/>
)}
</>
);
};
MachineContainer.propTypes = {
machine: PropTypes.object.isRequired,
viewMode: PropTypes.string.isRequired
};
export default MachineContainer;

View File

@ -0,0 +1,74 @@
import React from "react";
import PropTypes from "prop-types";
import { TableCell, TableRow, IconButton, Collapse } from "@material-ui/core";
import { KeyboardArrowDown, KeyboardArrowUp } from "@material-ui/icons";
import { makeStyles } from "@material-ui/core/styles";
import MachineCollapsedContent from "./common/MachineCollapsedContent";
import { useSensitiveInfo } from "../../../hooks";
import ActionsGroup from "./common/ActionsGroup";
const useRowStyles = makeStyles({
root: {
"& > *": {
borderBottom: "unset"
}
}
});
const MachineTableRow = ({ machine, actions, logs, addLog }) => {
const [open, setOpen] = React.useState(false);
const classes = useRowStyles();
const { mask } = useSensitiveInfo();
return (
<React.Fragment>
<TableRow className={classes.root}>
<TableCell>
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
>
{open ? <KeyboardArrowUp /> : <KeyboardArrowDown />}
</IconButton>
</TableCell>
<TableCell component="th" scope="row">
{mask(machine.fullMachineName)}
</TableCell>
<TableCell>{mask(machine.machineName)}</TableCell>
<TableCell>{mask(machine.iPv4Address)}</TableCell>
<TableCell>{mask(machine.macAddress)}</TableCell>
<TableCell align="right">
<ActionsGroup machine={machine} actions={actions} addLog={addLog} />
</TableCell>
</TableRow>
<TableRow>
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={6}>
<Collapse in={open} timeout="auto" unmountOnExit>
<MachineCollapsedContent
description={machine.description}
logs={logs}
style={{ paddingBottom: "10px" }}
/>
</Collapse>
</TableCell>
</TableRow>
</React.Fragment>
);
};
MachineTableRow.propTypes = {
machine: PropTypes.shape({
machineId: PropTypes.number.isRequired,
machineName: PropTypes.string.isRequired,
fullMachineName: PropTypes.string.isRequired,
macAddress: PropTypes.string.isRequired,
iPv4Address: PropTypes.string,
description: PropTypes.string
}).isRequired,
actions: PropTypes.array.isRequired,
logs: PropTypes.array.isRequired,
addLog: PropTypes.func.isRequired
};
export default MachineTableRow;

View File

@ -0,0 +1,55 @@
import React, { useContext, useEffect, useCallback, useState } from "react";
import {
NetworkStateContext,
NetworkDispatchContext
} from "../../network/state/contexts";
import MachinesListComponent from "./MachinesListComponent";
import PageTitle from "../../../components/common/PageTitle";
import { useTranslation } from "react-i18next";
import ViewModeSelection, { ViewModes } from "./ViewModeSelection";
import { routes, get } from "../../../utils/api";
const MachinesContainer = () => {
const [viewMode, setViewMode] = useState(null);
const state = useContext(NetworkStateContext);
const dispatchActions = useContext(NetworkDispatchContext);
const { t } = useTranslation();
const handleReadMachines = useCallback(async () => {
await get(routes.machines, {
onCompleted: machines => {
const data = Object.assign(machines, { loaded: true });
dispatchActions.onNetworkChange("machines", data);
}
});
}, [dispatchActions]);
useEffect(() => {
if (!state.network.machines.loaded) {
handleReadMachines();
}
}, [handleReadMachines, state.network.machines.loaded]);
return (
<>
<PageTitle
text={t("Menu.Machines")}
toolBar={
<ViewModeSelection
callback={setViewMode}
initialMode={ViewModes.TABLE}
/>
}
/>
{viewMode && (
<MachinesListComponent
machines={state.network.machines}
viewMode={viewMode}
/>
)}
</>
);
};
export default MachinesContainer;

View File

@ -0,0 +1,80 @@
import React from "react";
import PropTypes from "prop-types";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow
} from "@material-ui/core";
import Paper from "@material-ui/core/Paper";
import MachineContainer from "./MachineContainer";
import { useTranslation } from "react-i18next";
import { ViewModes } from "./ViewModeSelection";
const MachinesList = ({ machines, viewMode }) => {
return (
<>
{machines.map(machine => (
<MachineContainer
key={`machine-${machine.machineId}`}
machine={machine}
viewMode={viewMode}
/>
))}
</>
);
};
MachinesList.propTypes = {
machines: PropTypes.array.isRequired,
viewMode: PropTypes.string.isRequired
};
const MachinesTableList = ({ machines, viewMode }) => {
const { t } = useTranslation();
return (
<TableContainer component={Paper}>
<Table aria-label="collapsible table" size="small">
<TableHead>
<TableRow>
<TableCell />
<TableCell>{t("Machine.FullName")}</TableCell>
<TableCell>{t("Machine.Name")}</TableCell>
<TableCell>{t("Machine.IP")}</TableCell>
<TableCell>{t("Machine.MAC")}</TableCell>
<TableCell align="right" />
</TableRow>
</TableHead>
<TableBody>
<MachinesList machines={machines} viewMode={viewMode} />
</TableBody>
</Table>
</TableContainer>
);
};
MachinesTableList.propTypes = {
machines: PropTypes.array.isRequired,
viewMode: PropTypes.string.isRequired
};
const MachinesListComponent = ({ machines, viewMode }) => {
return (
<>
{viewMode === ViewModes.TABLE ? (
<MachinesTableList machines={machines} viewMode={viewMode} />
) : (
<MachinesList machines={machines} viewMode={viewMode} />
)}
</>
);
};
MachinesListComponent.propTypes = {
machines: PropTypes.array.isRequired,
viewMode: PropTypes.string.isRequired
};
export default MachinesListComponent;

View File

@ -0,0 +1,80 @@
import React, { useState, useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import TableChartIcon from "@material-ui/icons/TableChart";
import ViewListIcon from "@material-ui/icons/ViewList";
import ToggleButton from "@material-ui/lab/ToggleButton";
import ToggleButtonGroup from "@material-ui/lab/ToggleButtonGroup";
import { Tooltip } from "@material-ui/core";
import { useTranslation } from "react-i18next";
export const ViewModes = {
TABLE: "table",
ACCORDION: "accordion"
};
const ViewModeSelection = ({ initialMode, callback }) => {
const [state, setState] = useState({
mode: initialMode,
manual: false
});
const { t } = useTranslation();
const handleViewModeSelection = useCallback((event, mode) => {
setState({ mode, manual: true });
}, []);
useEffect(() => {
const mediaQuery = window.matchMedia("(max-width: 1100px)");
function handleMatches(event) {
if (state.manual === true) return;
const mode = event.matches ? ViewModes.ACCORDION : ViewModes.TABLE;
setState({ mode, manual: false });
}
handleMatches(mediaQuery);
mediaQuery.addListener(handleMatches);
return () => {
mediaQuery.removeListener(handleMatches);
};
}, [state.manual]);
useEffect(() => callback && callback(state.mode), [callback, state.mode]);
return (
<ToggleButtonGroup
size="small"
value={state.mode}
exclusive
onChange={handleViewModeSelection}
>
<ToggleButton
value={ViewModes.TABLE}
aria-label="table view mode"
disabled={state.mode === ViewModes.TABLE}
>
<Tooltip title={t("ViewModes.Table")}>
<TableChartIcon />
</Tooltip>
</ToggleButton>
<ToggleButton
value={ViewModes.ACCORDION}
aria-label="accordion view mode"
disabled={state.mode === ViewModes.ACCORDION}
>
<Tooltip title={t("ViewModes.List")}>
<ViewListIcon />
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>
);
};
ViewModeSelection.propTypes = {
initialMode: PropTypes.oneOf([ViewModes.TABLE, ViewModes.ACCORDION]),
callback: PropTypes.func
};
export default ViewModeSelection;

View File

@ -0,0 +1,47 @@
import React from "react";
import PropTypes from "prop-types";
import { IconButton, Tooltip } from "@material-ui/core";
const ActionButton = React.forwardRef((props, _ref) => {
const { action, machine, callback, disabled } = props;
const id = `machine-item-${machine.machineId}-${action.code}`;
const handleActionClick = event => {
action.effect(machine, event);
callback && callback(machine);
event.stopPropagation();
};
return (
<Tooltip
id={`machine-item-${machine.machineId}-${action.code}-tooltip`}
title={action.tooltip}
>
<span>
<IconButton
id={id}
size={"small"}
onFocus={event => event.stopPropagation()}
onClick={handleActionClick}
disabled={disabled}
>
<action.icon />
</IconButton>
</span>
</Tooltip>
);
});
ActionButton.propTypes = {
machine: PropTypes.shape({
machineId: PropTypes.number.isRequired
}).isRequired,
action: PropTypes.shape({
code: PropTypes.string.isRequired,
tooltip: PropTypes.string.isRequired,
effect: PropTypes.func.isRequired
}).isRequired,
callback: PropTypes.func,
disabled: PropTypes.bool
};
export default ActionButton;

View File

@ -0,0 +1,93 @@
import React, { useMemo, useState } from "react";
import PropTypes from "prop-types";
import WakeComponent from "./WakeComponent";
import ActionButton from "./ActionButton";
import { Menu } from "@material-ui/core";
import { MoreHoriz } from "@material-ui/icons";
import { useTranslation } from "react-i18next";
import { usePermissions } from "../../../../hooks";
const ActionsGroup = ({ machine, actions, addLog }) => {
const [menuAnchor, setMenuAnchor] = useState(null);
const { t } = useTranslation();
const { operateMachines: canOperateMachines } = usePermissions();
const mainActions = useMemo(
() => actions.filter(a => a.main === true),
[actions]
);
const secondaryActions = useMemo(
() => actions.filter(a => a.main === false),
[actions]
);
const handleMenuOpen = (_, event) => {
setMenuAnchor(event.currentTarget);
};
const handleMenuClose = () => {
setMenuAnchor(null);
};
return (
<>
<WakeComponent
machine={machine}
addLog={addLog}
disabled={!canOperateMachines}
/>
{mainActions.map(action => (
<ActionButton
key={`machine-item-${machine.machineId}-${action.code}`}
action={action}
machine={machine}
disabled={!canOperateMachines}
/>
))}
<ActionButton
action={{
code: "more",
effect: handleMenuOpen,
icon: MoreHoriz,
tooltip: t("Machine.Actions.More")
}}
machine={machine}
/>
<Menu
id="secondary-actions-menu"
anchorEl={menuAnchor}
keepMounted
open={Boolean(menuAnchor)}
onClose={handleMenuClose}
>
{secondaryActions.map(action => (
<ActionButton
key={`machine-item-${machine.machineId}-${action.code}`}
action={action}
machine={machine}
callback={handleMenuClose}
disabled={!canOperateMachines}
/>
))}
</Menu>
</>
);
};
ActionsGroup.propTypes = {
machine: PropTypes.shape({
machineId: PropTypes.number.isRequired,
machineName: PropTypes.string.isRequired,
fullMachineName: PropTypes.string.isRequired,
macAddress: PropTypes.string.isRequired,
iPv4Address: PropTypes.string,
description: PropTypes.string
}).isRequired,
actions: PropTypes.array.isRequired,
addLog: PropTypes.func.isRequired
};
export default ActionsGroup;

View File

@ -0,0 +1,56 @@
import React from "react";
import PropTypes from "prop-types";
import MachineLog from "./MachineLog";
import Typography from "@material-ui/core/Typography";
import { useSensitiveInfo } from "../../../../hooks";
import { makeStyles } from "@material-ui/core/styles";
const useStyles = makeStyles(_theme => ({
panel: {
display: "flex"
},
label: {
marginRight: "4px"
}
}));
const MachineDescription = ({ description }) => {
const classes = useStyles();
return (
<div className={classes.panel}>
<Typography
variant="body2"
className={classes.label}
color="textSecondary"
>
{"Description:"}
</Typography>
<Typography variant="body2" color="textSecondary">
{description}
</Typography>
</div>
);
};
MachineDescription.propTypes = {
description: PropTypes.string
};
const MachineCollapsedContent = ({ description, logs, style }) => {
const { mask } = useSensitiveInfo();
return (
<div style={style}>
<MachineLog logs={logs} />
{description && <MachineDescription description={mask(description)} />}
</div>
);
};
MachineCollapsedContent.propTypes = {
logs: PropTypes.array.isRequired,
description: PropTypes.string,
style: PropTypes.object
};
export default MachineCollapsedContent;

View File

@ -0,0 +1,40 @@
import React, { useMemo } from "react";
import PropTypes from "prop-types";
import { Box } from "@material-ui/core";
import { useSensitiveInfo } from "../../../../hooks";
import { LazyLog, ScrollFollow } from "react-lazylog";
const MachineLog = ({ logs }) => {
const { maskElements } = useSensitiveInfo();
const displayLogs = useMemo(
() => (logs.length > 0 ? maskElements(logs).join("\n") : "..."),
[logs, maskElements]
);
return (
<Box>
<div style={{ height: 200 }}>
<ScrollFollow
startFollowing={true}
render={({ follow, onScroll }) => (
<LazyLog
extraLines={0}
enableSearch
text={displayLogs}
caseInsensitive
follow={follow}
onScroll={onScroll}
/>
)}
/>
</div>
</Box>
);
};
MachineLog.propTypes = {
logs: PropTypes.array.isRequired
};
export default MachineLog;

View File

@ -0,0 +1,119 @@
import React, { useState, useEffect, useCallback } from "react";
import PropTypes from "prop-types";
import { IconButton, Tooltip } from "@material-ui/core";
import { PowerSettingsNew } from "@material-ui/icons";
import { useTranslation } from "react-i18next";
import { useToast } from "../../../../hooks";
import { msToMinAndSec } from "../../../../utils/time";
import { routes, post } from "../../../../utils/api";
const initialState = { on: false };
const defaultPingInterval = 1200000; //20 minutes
const defaultStartingTime = 300000; //5 minutes
const WakeComponent = ({ machine, addLog, disabled }) => {
const [state, setState] = useState(initialState);
const [trigger, setTrigger] = useState(false);
const { t } = useTranslation();
const { success, error } = useToast();
const pingInterval =
process.env.REACT_APP_MACHINE_PING_INTERVAL || defaultPingInterval;
const startingTime =
process.env.REACT_APP_MACHINE_STARTING_TIME || defaultStartingTime;
const getCurrentDateTime = useCallback(() => {
const currentDateTime = Date.now();
const result = t("DATE_FORMAT", {
date: { value: currentDateTime, format: "DD-MM-YYYY HH:mm:ss" }
});
return result;
}, [t]);
const log = useCallback(
message => addLog(`[${getCurrentDateTime()}] ${message}`),
[addLog, getCurrentDateTime]
);
const wakeMachine = useCallback(async () => {
await post(
routes.wakeMachine,
{ machineId: machine.machineId },
{
onCompleted: result => {
setState(prev => ({ ...prev, on: result.success }));
log(`[Wake]: Success: ${result.success}. Status: ${result.status}`);
if (result.success) {
success(result.status);
//retrigger
log(
`Periodic ping will be re-triggered in ${startingTime} ms [${msToMinAndSec(
startingTime
)}]`
);
setTimeout(() => {
setTrigger(prev => !prev);
}, startingTime);
} else {
error(result.status);
}
}
}
);
}, [log, success, error, startingTime, machine.machineId]);
const pingInLoop = useCallback(async () => {
if (disabled) return;
await post(
routes.pingMachine,
{ machineId: machine.machineId },
{
onCompleted: result => {
setState(prev => ({ ...prev, on: result.success }));
log(`[Ping]: Success: ${result.success}. Status: ${result.status}`);
if (result.success) {
setTimeout(() => {
setTrigger(prev => !prev);
}, pingInterval);
}
},
onError: () => {}
}
);
}, [machine, log, pingInterval, disabled]);
useEffect(pingInLoop, [trigger, pingInLoop]);
const handleWakeClick = event => {
wakeMachine();
event.stopPropagation();
};
return (
<Tooltip title={t(state.on ? "Machine.PoweredOn" : "Machine.Actions.Wake")}>
<span>
<IconButton
id={`machine-${machine.machineId}-wake`}
size={"small"}
disabled={disabled || state.on}
onClick={handleWakeClick}
style={state.on ? { color: "#33cc33" } : undefined}
onFocus={event => event.stopPropagation()}
>
<PowerSettingsNew />
</IconButton>
</span>
</Tooltip>
);
};
WakeComponent.propTypes = {
machine: PropTypes.object.isRequired,
addLog: PropTypes.func.isRequired,
disabled: PropTypes.bool
};
export default WakeComponent;

View File

@ -0,0 +1,20 @@
import React from "react";
import MachinesContainer from "../../machines/components/MachinesContainer";
import NetworkStateProvider from "../state/NetworkStateProvider";
import { usePermissions } from "../../../hooks";
import NotAllowed from "../../../components/common/NotAllowed";
const NetworkContainer = () => {
const { loading, viewMachines } = usePermissions();
if (loading) return "";
if (!viewMachines) return <NotAllowed />;
return (
<NetworkStateProvider>
<MachinesContainer />
</NetworkStateProvider>
);
};
export default NetworkContainer;

View File

@ -0,0 +1,27 @@
import React, { useReducer, useMemo } from "react";
import PropTypes from "prop-types";
import { NetworkStateContext, NetworkDispatchContext } from "./contexts";
import { reducer, dispatchActions as reducerDispatchActions } from "./reducer";
import { initialState } from "./initialState";
const NetworkStateProvider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
const dispatchActions = useMemo(
() => reducerDispatchActions(dispatch),
[dispatch]
);
return (
<NetworkStateContext.Provider value={state}>
<NetworkDispatchContext.Provider value={dispatchActions}>
{children}
</NetworkDispatchContext.Provider>
</NetworkStateContext.Provider>
);
};
NetworkStateProvider.propTypes = {
children: PropTypes.node.isRequired
};
export default NetworkStateProvider;

View File

@ -0,0 +1,4 @@
import React from "react";
export const NetworkStateContext = React.createContext();
export const NetworkDispatchContext = React.createContext();

View File

@ -0,0 +1,6 @@
export const initialState = {
network: {
machines: Object.assign([], { loaded: false }),
test: ""
}
};

View File

@ -0,0 +1,22 @@
export function reducer(state, action) {
switch (action.type) {
case "onNetworkChange": {
const { prop, value } = action.payload;
return {
...state,
network: {
...state.network,
[prop]: value
}
};
}
default: {
return state;
}
}
}
export const dispatchActions = dispatch => ({
onNetworkChange: (prop, value) =>
dispatch({ type: "onNetworkChange", payload: { prop, value } })
});

View File

@ -0,0 +1,57 @@
import React, { useState, useMemo } from "react";
import BubbleChartIcon from "@material-ui/icons/BubbleChart";
import BrushIcon from "@material-ui/icons/Brush";
import NotificationsIcon from "@material-ui/icons/Notifications";
import { useTranslation } from "react-i18next";
import PageTitle from "../../components/common/PageTitle";
import NavigationButtons from "../../components/common/NavigationButtons";
import SystemContainer from "./system/SystemContainer";
import AppearanceContainer from "./appearance/AppearanceContainer";
import NotificationsContainer from "./notifications/NotificationsContainer";
const NavigationTabs = {
SYSTEM: "Settings.Navigation.System",
APPEARANCE: "Settings.Navigation.Appearance",
NOTIFICATIONS: "Settings.Navigation.Notifications"
};
const tabs = [
{
code: NavigationTabs.SYSTEM,
icon: BubbleChartIcon
},
{
code: NavigationTabs.APPEARANCE,
icon: BrushIcon
},
{
code: NavigationTabs.NOTIFICATIONS,
icon: NotificationsIcon
}
];
const SettingsContainer = () => {
const [tab, setTab] = useState(NavigationTabs.SYSTEM);
const { t } = useTranslation();
const navigationTabs = useMemo(
() => tabs.map(z => ({ ...z, tooltip: t(z.code) })),
[t]
);
return (
<>
<PageTitle
text={t(tab)}
navigation={
<NavigationButtons tabs={navigationTabs} onTabChange={setTab} />
}
/>
{tab === NavigationTabs.SYSTEM && <SystemContainer />}
{tab === NavigationTabs.APPEARANCE && <AppearanceContainer />}
{tab === NavigationTabs.NOTIFICATIONS && <NotificationsContainer />}
</>
);
};
export default SettingsContainer;

View File

@ -0,0 +1,61 @@
import React from "react";
import { useApplicationTheme } from "../../../providers/ThemeProvider";
import { Grid, Paper, FormControlLabel, Switch } from "@material-ui/core";
import LanguageContainer from "./language/LanguageContainer";
import { PaperTitle } from "components/common";
import { makeStyles } from "@material-ui/core/styles";
import { useTranslation } from "react-i18next";
const useStyles = makeStyles(theme => ({
language: {
paddingLeft: theme.spacing(1)
}
}));
const AppearanceComponent = () => {
const { isDark, onDarkModeChanged } = useApplicationTheme();
const { t } = useTranslation();
const classes = useStyles();
const handleChange = event => {
const { checked } = event.target;
onDarkModeChanged(checked);
};
return (
<Paper variant="outlined">
<PaperTitle text={t("Settings.Appearance.Title")} />
<Grid container spacing={0}>
<Grid item xs={12} sm={6} md={4} lg={3}>
<FormControlLabel
value="start"
control={
<Switch
checked={isDark}
onChange={handleChange}
color="secondary"
name="dark-mode-switch"
/>
}
label="Dark mode:"
labelPlacement="start"
/>
</Grid>
<Grid item xs={12} sm={6} md={4} lg={3}>
<FormControlLabel
value="start"
control={
<div className={classes.language}>
<LanguageContainer />
</div>
}
label="Language:"
labelPlacement="start"
/>
</Grid>
</Grid>
</Paper>
);
};
export default AppearanceComponent;

View File

@ -0,0 +1,8 @@
import React from "react";
import AppearanceComponent from "./AppearanceComponent";
const AppearanceContainer = () => {
return <AppearanceComponent />;
};
export default AppearanceContainer;

View File

@ -0,0 +1,75 @@
import React from "react";
import PropTypes from "prop-types";
import Flag from "react-flags";
import { IconButton, Menu, MenuItem } from "@material-ui/core";
import { useTranslation } from "react-i18next";
const LanguageComponent = ({
languageIsSet,
anchorEl,
onMenuOpen,
onLanguageChange,
onClose,
flag,
flagsPath
}) => {
const { t } = useTranslation();
const open = Boolean(anchorEl);
return (
<>
<IconButton
aria-controls="language-menu"
aria-haspopup="true"
onClick={onMenuOpen}
color="inherit"
size="small"
>
{languageIsSet && (
<Flag
name={flag.name}
format="png"
pngSize={32}
shiny={true}
basePath={flagsPath}
alt={flag.alt}
/>
)}
</IconButton>
<Menu
id="language-menu"
anchorEl={anchorEl}
anchorOrigin={{
vertical: "top",
horizontal: "right"
}}
keepMounted
transformOrigin={{
vertical: "top",
horizontal: "left"
}}
open={open}
onClose={onClose}
>
<MenuItem onClick={onLanguageChange("ro")}>
{t("Language.Romanian")}
</MenuItem>
<MenuItem onClick={onLanguageChange("en")}>
{t("Language.English")}
</MenuItem>
</Menu>
</>
);
};
LanguageComponent.propTypes = {
languageIsSet: PropTypes.bool.isRequired,
anchorEl: PropTypes.object,
onMenuOpen: PropTypes.func.isRequired,
onLanguageChange: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
flag: PropTypes.object.isRequired,
flagsPath: PropTypes.string.isRequired
};
export default LanguageComponent;

View File

@ -0,0 +1,54 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import LanguageComponent from "./LanguageComponent";
const flagsPath = process.env.PUBLIC_URL
? `${process.env.PUBLIC_URL}/flags`
: "flags";
const LanguageContainer = () => {
const [anchorEl, setAnchorEl] = useState(null);
const { i18n } = useTranslation();
const [flag, setFlag] = useState({
name: "RO",
alt: "-"
});
useEffect(() => {
if (!i18n.language) return;
setFlag({
name: i18n.language === "en" ? "GB" : i18n.language.toUpperCase(),
alt: i18n.language
});
}, [i18n.language]);
const handleMenuOpen = event => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleLanguageChange = language => () => {
if (language !== i18n.language) {
i18n.changeLanguage(language);
}
setAnchorEl(null);
};
return (
<LanguageComponent
languageIsSet={i18n.language ? true : false}
anchorEl={anchorEl}
onMenuOpen={handleMenuOpen}
onLanguageChange={handleLanguageChange}
onClose={handleClose}
flag={flag}
flagsPath={flagsPath}
/>
);
};
export default LanguageContainer;

View File

@ -0,0 +1,13 @@
import React from "react";
const NotificationsContainer = () => {
return (
<div>
Enable/Disable email notifications (for each one separately - when
starting the machine, when stopping) You can go even further and have an
advanced site where you can configure each individual machine.
</div>
);
};
export default NotificationsContainer;

View File

@ -0,0 +1,43 @@
import React from "react";
import PropTypes from "prop-types";
import { Paper, Button } from "@material-ui/core";
import { makeStyles } from "@material-ui/core/styles";
import { useTranslation } from "react-i18next";
import { PaperTitle } from "components/common";
import { usePermissions } from "hooks";
const useStyles = makeStyles(theme => ({
content: {
"& > *": {
margin: theme.spacing(1)
}
}
}));
const CacheSettingsComponent = ({ onResetCache }) => {
const classes = useStyles();
const { t } = useTranslation();
const { sysAdmin } = usePermissions();
return (
<Paper variant="outlined">
<PaperTitle text={t("Settings.Cache.Title")} />
<div className={classes.content}>
<Button
variant="outlined"
color="secondary"
disabled={!sysAdmin}
onClick={onResetCache}
>
{t("Settings.Cache.Reset")}
</Button>
</div>
</Paper>
);
};
CacheSettingsComponent.propTypes = {
onResetCache: PropTypes.func.isRequired
};
export default CacheSettingsComponent;

View File

@ -0,0 +1,22 @@
import React, { useCallback } from "react";
import CacheSettingsComponent from "./CacheSettingsComponent";
import { useTranslation } from "react-i18next";
import { routes, post } from "utils/api";
import { info } from "utils/toast";
const CacheSettingsContainer = () => {
const { t } = useTranslation();
const handleResetCache = useCallback(async () => {
await post(
routes.resetCache,
{},
{
onCompleted: () => info(t("Settings.Cache.ResetInfo"))
}
);
}, [t]);
return <CacheSettingsComponent onResetCache={handleResetCache} />;
};
export default CacheSettingsContainer;

View File

@ -0,0 +1,8 @@
import React from "react";
import CacheSettingsContainer from "./CacheSettingsContainer";
const SystemContainer = () => {
return <CacheSettingsContainer />;
};
export default SystemContainer;

View File

@ -0,0 +1,7 @@
import React from "react";
const MainServicesContainer = () => {
return <div>MainServices</div>;
};
export default MainServicesContainer;

View File

@ -0,0 +1,49 @@
import React, { useState, useMemo } from "react";
import CategoryIcon from "@material-ui/icons/Category";
import GrainIcon from "@material-ui/icons/Grain";
import { useTranslation } from "react-i18next";
import PageTitle from "../../components/common/PageTitle";
import NavigationButtons from "../../components/common/NavigationButtons";
import MainServicesContainer from "./MainServicesContainer";
import AgentsContainer from "./agents/AgentsContainer";
const NavigationTabs = {
MAIN_SERVICES: "System.Navigation.MainServices",
AGENTS: "System.Navigation.Agents"
};
const tabs = [
{
code: NavigationTabs.MAIN_SERVICES,
icon: CategoryIcon
},
{
code: NavigationTabs.AGENTS,
icon: GrainIcon
}
];
const SystemContainer = () => {
const [tab, setTab] = useState(NavigationTabs.MAIN_SERVICES);
const { t } = useTranslation();
const navigationTabs = useMemo(
() => tabs.map(z => ({ ...z, tooltip: t(z.code) })),
[t]
);
return (
<>
<PageTitle
text={t(tab)}
navigation={
<NavigationButtons tabs={navigationTabs} onTabChange={setTab} />
}
/>
{tab === NavigationTabs.MAIN_SERVICES && <MainServicesContainer />}
{tab === NavigationTabs.AGENTS && <AgentsContainer />}
</>
);
};
export default SystemContainer;

View File

@ -0,0 +1,7 @@
import React from "react";
const AgentsContainer = () => {
return <div>Agents</div>;
};
export default AgentsContainer;

View File

@ -0,0 +1,45 @@
import React, { useMemo } from "react";
import PropTypes from "prop-types";
import UserProfilePicture from "./UserProfilePicture";
import ContactOptions from "../contact/ContactOptions";
import { makeStyles } from "@material-ui/core/styles";
import styles from "../styles";
const useStyles = makeStyles(styles);
const UserProfileCardContent = ({ userData }) => {
const { profilePictureUrl } = userData;
const classes = useStyles();
const userName = useMemo(
() => `${userData.firstName} ${userData.lastName}`,
[userData.firstName, userData.lastName]
);
const _contactOptions = useMemo(
() =>
profilePictureUrl
? [
...userData.contactOptions,
{
id: userData.contactOptions.length + 1,
contactTypeCode: "PROFILE_PICTURE",
contactValue: profilePictureUrl
}
]
: userData.contactOptions,
[profilePictureUrl, userData.contactOptions]
);
return (
<div className={classes.panel}>
<UserProfilePicture pictureUrl={userData.profilePictureUrl} />
<ContactOptions contactOptions={_contactOptions} userName={userName} />
</div>
);
};
UserProfileCardContent.propTypes = {
userData: PropTypes.object.isRequired
};
export default UserProfileCardContent;

View File

@ -0,0 +1,56 @@
import React, { useMemo } from "react";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
import { Card, CardHeader, CardContent } from "@material-ui/core";
import PageTitle from "../../../../components/common/PageTitle";
import UserProfileCardContent from "./UserProfileCardContent";
import SecurityComponent from "../security/SecurityComponent";
import styles from "../styles";
import { makeStyles } from "@material-ui/core/styles";
const useStyles = makeStyles(styles);
const UserProfileComponent = ({ userData }) => {
const { t } = useTranslation();
const classes = useStyles();
const userLoginDate = useMemo(
() =>
t("DATE_FORMAT", {
date: { value: userData.lastLoginDate, format: "DD-MM-YYYY HH:mm:ss" }
}),
[t, userData.lastLoginDate]
);
const userDescription = t("User.Profile.Description", {
userName: `${userData.firstName} ${userData.lastName}`,
loginDate: userLoginDate
});
return (
<>
<PageTitle
text={t("User.Profile.Hello", { userName: userData.firstName })}
/>
<Card>
<CardHeader title={userData.userName} subheader={userDescription} />
<CardContent>
<UserProfileCardContent userData={userData} />
</CardContent>
</Card>
<div className={classes.section}>
<SecurityComponent
userGroups={userData.userGroups}
userRoles={userData.userRoles}
/>
</div>
</>
);
};
UserProfileComponent.propTypes = {
userData: PropTypes.object.isRequired
};
export default UserProfileComponent;

View File

@ -0,0 +1,10 @@
import React from "react";
import { useTuitioUserInfo } from "@flare/tuitio-client-react";
import UserProfileComponent from "./UserProfileComponent";
const UserProfileContainer = () => {
const { userInfo } = useTuitioUserInfo();
return <>{userInfo && <UserProfileComponent userData={userInfo} />}</>;
};
export default UserProfileContainer;

View File

@ -0,0 +1,20 @@
import React from "react";
import PropTypes from "prop-types";
import { makeStyles } from "@material-ui/core/styles";
import style from "../styles";
import Avatar from "@material-ui/core/Avatar";
import DefaultUserProfilePicture from "../../../../assets/images/DefaultUserProfilePicture.png";
const useStyles = makeStyles(style);
const UserProfilePicture = ({ pictureUrl }) => {
const classes = useStyles();
const url = pictureUrl ?? DefaultUserProfilePicture;
return <Avatar src={url} alt="..." className={classes.profilePicture} />;
};
UserProfilePicture.propTypes = {
pictureUrl: PropTypes.string
};
export default UserProfilePicture;

View File

@ -0,0 +1,62 @@
import React from "react";
import PropTypes from "prop-types";
import {
ListItem,
ListItemText,
ListItemIcon,
Link,
Tooltip,
IconButton
} from "@material-ui/core";
const ContactIcon = ({ onIconClick, iconTooltip, ...props }) => {
if (!onIconClick) return <props.icon />;
return (
<Tooltip title={iconTooltip}>
<IconButton size="small" onClick={onIconClick}>
<props.icon />
</IconButton>
</Tooltip>
);
};
ContactIcon.propTypes = {
onIconClick: PropTypes.func,
iconTooltip: PropTypes.string
};
const ContactOption = ({ tooltip, label, link, onClick, ...props }) => {
const linkLabel = label ?? link;
return (
<ListItem dense>
<ListItemIcon>
<ContactIcon {...props} />
</ListItemIcon>
<ListItemText
primary={
<Tooltip title={tooltip}>
{onClick ? (
<Link href="#" onClick={onClick} style={{ fontWeight: "bold" }}>
{linkLabel}
</Link>
) : (
<Link href={link} target="_blank" style={{ fontWeight: "bold" }}>
{linkLabel}
</Link>
)}
</Tooltip>
}
/>
</ListItem>
);
};
ContactOption.propTypes = {
tooltip: PropTypes.string.isRequired,
label: PropTypes.string,
link: PropTypes.string.isRequired,
onClick: PropTypes.func,
onIconClick: PropTypes.func
};
export default ContactOption;

View File

@ -0,0 +1,29 @@
import React from "react";
import PropTypes from "prop-types";
import { List } from "@material-ui/core";
import ContactOption from "./ContactOption";
const ContactOptionList = ({ options }) => {
return (
<List>
{options.map((z, index) => (
<ContactOption
key={`contact_${index}_${z.id}`}
icon={z.icon}
tooltip={z.tooltip}
label={z.label}
link={z.link}
onClick={z.onClick}
onIconClick={z.onIconClick}
iconTooltip={z.iconTooltip}
/>
))}
</List>
);
};
ContactOptionList.propTypes = {
options: PropTypes.array.isRequired
};
export default ContactOptionList;

View File

@ -0,0 +1,178 @@
import React, { useCallback, useMemo } from "react";
import PropTypes from "prop-types";
import { useTranslation } from "react-i18next";
import { Grid } from "@material-ui/core";
import ContactOptionList from "./ContactOptionList";
import BusinessCenterIcon from "@material-ui/icons/BusinessCenter";
import EmailIcon from "@material-ui/icons/Email";
import PhoneAndroidIcon from "@material-ui/icons/PhoneAndroid";
import LanguageIcon from "@material-ui/icons/Language";
import LinkedInIcon from "@material-ui/icons/LinkedIn";
import GitHubIcon from "@material-ui/icons/GitHub";
import RedditIcon from "@material-ui/icons/Reddit";
import BookIcon from "@material-ui/icons/Book";
import MenuBookIcon from "@material-ui/icons/MenuBook";
import FileCopyOutlinedIcon from "@material-ui/icons/FileCopyOutlined";
import { useClipboard } from "../../../../hooks";
const icons = {
EMAIL: EmailIcon,
PHONE: PhoneAndroidIcon,
PORTFOLIO: BusinessCenterIcon,
LINKEDIN: LinkedInIcon,
GITHUB: GitHubIcon,
GITEA: GitHubIcon,
REDDIT: RedditIcon,
BLOG: BookIcon,
CURRICULUM_VITAE: MenuBookIcon,
PROFILE_PICTURE: FileCopyOutlinedIcon,
DEFAULT: LanguageIcon
};
const tooltips = {
EMAIL: "Generic.SendEmail",
PHONE: "Generic.Copy",
PORTFOLIO: "User.Profile.OpenPortfolio",
DEFAULT: "Generic.OpenInNewTab"
};
const orderNumbers = {
PORTFOLIO: 1,
EMAIL: 2,
PHONE: 3,
CURRICULUM_VITAE: 4,
LINKEDIN: 5,
PROFILE_PICTURE: 6,
GITEA: 7,
GITHUB: 8,
BLOG: 9,
WEBSITE: 10,
REDDIT: 12,
DEFAULT: 100
};
const getIcon = contactOption => {
const icon = icons[contactOption.contactTypeCode] || icons.DEFAULT;
return icon;
};
const getTooltip = contactOption => {
const tooltip = tooltips[contactOption.contactTypeCode] || tooltips.DEFAULT;
return tooltip;
};
const getOrderNumber = contactOption => {
const orderNo =
orderNumbers[contactOption.contactTypeCode] || orderNumbers.DEFAULT;
return orderNo;
};
const getLabel = (contactOption, userName) => {
switch (contactOption.contactTypeCode) {
case "EMAIL":
case "PHONE":
case "PROFILE_PICTURE":
return contactOption.contactValue;
case "PORTFOLIO":
return userName;
default:
return contactOption.contactTypeName;
}
};
const handleEmailSending = email => event => {
window.location.href = `mailto:${email}`;
event.preventDefault();
};
const chunkSize = 6;
const sliceContactOptions = options => {
const chunks = [];
for (let i = 0; i < options.length; i += chunkSize) {
chunks.push(options.slice(i, i + chunkSize));
}
return chunks;
};
const ContactOptions = ({ contactOptions, userName }) => {
const { t } = useTranslation();
const { copy } = useClipboard();
const getOnClickEvent = useCallback(
contactOption => {
switch (contactOption.contactTypeCode) {
case "EMAIL":
return handleEmailSending(contactOption.contactValue);
case "PHONE":
return copy(contactOption.contactValue);
default:
return undefined;
}
},
[copy]
);
const getIconClickEvent = useCallback(
contactOption => {
switch (contactOption.contactTypeCode) {
case "PROFILE_PICTURE":
return {
onIconClick: copy(contactOption.contactValue),
iconTooltip: t("Generic.Copy")
};
default:
return { onIconClick: undefined, iconTooltip: undefined };
}
},
[copy, t]
);
const enrichedContactOptions = useMemo(
() =>
contactOptions.map(co => {
const icon = getIcon(co);
const tooltip = getTooltip(co);
const label = getLabel(co, userName);
const onClick = getOnClickEvent(co);
const { onIconClick, iconTooltip } = getIconClickEvent(co);
const orderNo = getOrderNumber(co);
const option = {
id: co.id,
icon,
tooltip: t(tooltip),
label,
link: co.contactValue,
onClick,
onIconClick,
iconTooltip,
orderNo
};
return option;
}),
[contactOptions, getOnClickEvent, getIconClickEvent, t, userName]
);
const sorted = useMemo(
() => enrichedContactOptions.sort((a, b) => a.orderNo - b.orderNo),
[enrichedContactOptions]
);
const chunks = useMemo(() => sliceContactOptions(sorted), [sorted]);
return (
<Grid container>
{chunks.map((chunk, index) => (
<Grid item xs={12} md={6} key={index}>
<ContactOptionList options={chunk} />
</Grid>
))}
</Grid>
);
};
ContactOptions.propTypes = {
contactOptions: PropTypes.array,
userName: PropTypes.string.isRequired
};
export default ContactOptions;

View File

@ -0,0 +1,55 @@
import React from "react";
import PropTypes from "prop-types";
import { Paper, Grid, Chip, Typography } from "@material-ui/core";
import styles from "../styles";
import { makeStyles } from "@material-ui/core/styles";
import { useTranslation } from "react-i18next";
const useStyles = makeStyles(styles);
const SecurityComponent = ({ userGroups, userRoles }) => {
const { t } = useTranslation();
const classes = useStyles();
return (
<Paper>
<Grid container>
<Grid item xs={12} md={6}>
<div className={classes.paper}>
<Typography gutterBottom variant="body1">
{t("User.Profile.Security.UserGroups")}
</Typography>
<div>
{userGroups.map(g => (
<Chip key={g.code} className={classes.chip} label={g.name} />
))}
</div>
</div>
</Grid>
<Grid item xs={12} md={6}>
<div className={classes.paper}>
<Typography gutterBottom variant="body1">
{t("User.Profile.Security.UserRoles")}
</Typography>
<div>
{userRoles.map(r => (
<Chip
key={r.code}
className={classes.chip}
color="primary"
label={r.name}
/>
))}
</div>
</div>
</Grid>
</Grid>
</Paper>
);
};
SecurityComponent.propTypes = {
userGroups: PropTypes.array.isRequired,
userRoles: PropTypes.array.isRequired
};
export default SecurityComponent;

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