Compare commits
14 Commits
23acd4dcf2
...
41656b1363
Author | SHA1 | Date |
---|---|---|
Tudor Stanciu | 41656b1363 | |
Tudor Stanciu | 2b6751335f | |
Tudor Stanciu | 4dff02b87f | |
Tudor Stanciu | feab019df1 | |
Tudor Stanciu | 63c7d946e9 | |
Tudor Stanciu | 27be2a2844 | |
Tudor Stanciu | 3a211bcb17 | |
Tudor Stanciu | 0e025e6c68 | |
Tudor Stanciu | b7affa7677 | |
Tudor Stanciu | b9726950cc | |
Tudor Stanciu | 6bad079252 | |
Tudor Stanciu | 1d58d0f5e7 | |
Tudor Stanciu | 1d81b8ec49 | |
Tudor Stanciu | 4c9491cb74 |
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "network-resurrector-frontend",
|
"name": "network-resurrector-frontend",
|
||||||
"version": "1.2.3",
|
"version": "1.2.4",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -1316,21 +1316,21 @@
|
||||||
"integrity": "sha512-6NBXdZgRrHbLXw4EMgyqCIzOVAlUgr1+8QGHjlA+n5Iw2Lp/+dP3FTgAfPW/cHR/PBI3cj7gUDVUf/zD/qTPOQ=="
|
"integrity": "sha512-6NBXdZgRrHbLXw4EMgyqCIzOVAlUgr1+8QGHjlA+n5Iw2Lp/+dP3FTgAfPW/cHR/PBI3cj7gUDVUf/zD/qTPOQ=="
|
||||||
},
|
},
|
||||||
"@flare/tuitio-client": {
|
"@flare/tuitio-client": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.5",
|
||||||
"resolved": "https://lab.code-rove.com/public-node-registry/@flare/tuitio-client/-/tuitio-client-1.2.2.tgz",
|
"resolved": "https://lab.code-rove.com/public-node-registry/@flare/tuitio-client/-/tuitio-client-1.2.5.tgz",
|
||||||
"integrity": "sha512-Rqz9seWFY5nUivMHrONphgYEeVriAq3yn8icAUQ+TFgf/ajoOqv0GlVfo2Nc//vqsLBJkZtHexmF92/5WHvvhQ==",
|
"integrity": "sha512-jxTn5n6vbUazRnCGCVWD0D3f41aYU5idjXfgUKVFSt92L+PI59u4Zd/lWcgjLRXRyY21IZGYr47T82k25r3siQ==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@flare/js-utils": "^1.1.0",
|
"@flare/js-utils": "^1.1.0",
|
||||||
"axios": "^1.3.2"
|
"axios": "^1.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@flare/tuitio-client-react": {
|
"@flare/tuitio-client-react": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.6",
|
||||||
"resolved": "https://lab.code-rove.com/public-node-registry/@flare/tuitio-client-react/-/tuitio-client-react-1.2.2.tgz",
|
"resolved": "https://lab.code-rove.com/public-node-registry/@flare/tuitio-client-react/-/tuitio-client-react-1.2.6.tgz",
|
||||||
"integrity": "sha512-mhO1psmLgNXe2bIYwi+2J1WYQM63IDr8162M3s3tjm4V+9pyM0lQs7EGIXJfn344TSmXFde3SZwyxakIyWeIbQ==",
|
"integrity": "sha512-T2uo9r9fQKTsbC25EaQnSifoDKrw3tgwy6fT/O1BdO4RPCUiRDr0YHiIssgPat+sB1G7iiUtV9CS8UNTJkacXw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"@flare/js-utils": "^1.1.0",
|
"@flare/js-utils": "^1.1.0",
|
||||||
"@flare/tuitio-client": "^1.2.2"
|
"@flare/tuitio-client": "^1.2.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@gar/promisify": {
|
"@gar/promisify": {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "network-resurrector-frontend",
|
"name": "network-resurrector-frontend",
|
||||||
"version": "1.2.3",
|
"version": "1.2.4",
|
||||||
"description": "Frontend component of Network resurrector system",
|
"description": "Frontend component of Network resurrector system",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Tudor Stanciu",
|
"name": "Tudor Stanciu",
|
||||||
|
@ -14,7 +14,7 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@flare/js-utils": "^1.1.0",
|
"@flare/js-utils": "^1.1.0",
|
||||||
"@flare/tuitio-client-react": "^1.2.2",
|
"@flare/tuitio-client-react": "^1.2.6",
|
||||||
"@material-ui/core": "^4.11.2",
|
"@material-ui/core": "^4.11.2",
|
||||||
"@material-ui/icons": "^4.11.2",
|
"@material-ui/icons": "^4.11.2",
|
||||||
"@material-ui/lab": "^4.0.0-alpha.61",
|
"@material-ui/lab": "^4.0.0-alpha.61",
|
||||||
|
|
|
@ -4,3 +4,11 @@ https://v4.mui.com/components/material-icons/
|
||||||
|
|
||||||
Theming:
|
Theming:
|
||||||
https://v4.mui.com/customization/palette/ (Dark theme)
|
https://v4.mui.com/customization/palette/ (Dark theme)
|
||||||
|
|
||||||
|
Add in settings:
|
||||||
|
- reset cache
|
||||||
|
- ping interval
|
||||||
|
- notifications
|
||||||
|
- test notification mechanism
|
||||||
|
- permissions
|
||||||
|
- permissions hierarchy
|
|
@ -36,12 +36,34 @@
|
||||||
"Label": "Login",
|
"Label": "Login",
|
||||||
"IncorrectCredentials": "Incorrect credentials."
|
"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": {
|
"User": {
|
||||||
"Profile": {
|
"Profile": {
|
||||||
"Label": "Profile",
|
"Label": "Profile",
|
||||||
"Hello": "Hi, {{userName}}",
|
"Hello": "Hi, {{userName}}",
|
||||||
"Description": "{{userName}}, authenticated on {{loginDate}}",
|
"Description": "{{userName}}, authenticated on {{loginDate}}",
|
||||||
"OpenPortfolio": "Open portfolio"
|
"OpenPortfolio": "Open portfolio",
|
||||||
|
"Security": {
|
||||||
|
"UserGroups": "User groups",
|
||||||
|
"UserRoles": "User roles"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Settings": "Settings",
|
"Settings": "Settings",
|
||||||
"Logout": "Logout"
|
"Logout": "Logout"
|
||||||
|
@ -51,6 +73,7 @@
|
||||||
"Name": "Machine name",
|
"Name": "Machine name",
|
||||||
"IP": "IP",
|
"IP": "IP",
|
||||||
"MAC": "MAC address",
|
"MAC": "MAC address",
|
||||||
|
"Description": "Description",
|
||||||
"PoweredOn": "Powered on",
|
"PoweredOn": "Powered on",
|
||||||
"Actions": {
|
"Actions": {
|
||||||
"Wake": "Wake",
|
"Wake": "Wake",
|
||||||
|
|
|
@ -27,12 +27,34 @@
|
||||||
"Label": "Autentificare",
|
"Label": "Autentificare",
|
||||||
"IncorrectCredentials": "Credențiale incorecte."
|
"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": {
|
"User": {
|
||||||
"Profile": {
|
"Profile": {
|
||||||
"Label": "Profil",
|
"Label": "Profil",
|
||||||
"Hello": "Salut, {{userName}}",
|
"Hello": "Salut, {{userName}}",
|
||||||
"Description": "{{userName}}, autentificat pe {{loginDate}}",
|
"Description": "{{userName}}, autentificat pe {{loginDate}}",
|
||||||
"OpenPortfolio": "Deschide portofoliu"
|
"OpenPortfolio": "Deschide portofoliu",
|
||||||
|
"Security": {
|
||||||
|
"UserGroups": "Grupuri utilizator",
|
||||||
|
"UserRoles": "Roluri utilizator"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"Settings": "Setări",
|
"Settings": "Setări",
|
||||||
"Logout": "Deconectare"
|
"Logout": "Deconectare"
|
||||||
|
@ -42,6 +64,7 @@
|
||||||
"Name": "Nume masina",
|
"Name": "Nume masina",
|
||||||
"IP": "IP",
|
"IP": "IP",
|
||||||
"MAC": "Adresa MAC",
|
"MAC": "Adresa MAC",
|
||||||
|
"Description": "Descriere",
|
||||||
"PoweredOn": "Pornit",
|
"PoweredOn": "Pornit",
|
||||||
"Actions": {
|
"Actions": {
|
||||||
"Wake": "Pornește",
|
"Wake": "Pornește",
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
import useApi from "./useApi";
|
|
||||||
|
|
||||||
export default useApi;
|
|
|
@ -1,123 +0,0 @@
|
||||||
import { useToast } from "../hooks";
|
|
||||||
import { get, post } from "../utils/axios";
|
|
||||||
|
|
||||||
const networkRoute = `${process.env.REACT_APP_NETWORK_RESURRECTOR_API_URL}/network`;
|
|
||||||
const systemRoute = `${process.env.REACT_APP_NETWORK_RESURRECTOR_API_URL}/system`;
|
|
||||||
const powerActionsRoute = `${process.env.REACT_APP_NETWORK_RESURRECTOR_API_URL}/resurrector`;
|
|
||||||
|
|
||||||
const useApi = () => {
|
|
||||||
const { error } = useToast();
|
|
||||||
|
|
||||||
const handleError = err => {
|
|
||||||
let message;
|
|
||||||
switch (err?.status) {
|
|
||||||
case 500:
|
|
||||||
message = err.title;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 404:
|
|
||||||
message = err.message;
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
message = err.title;
|
|
||||||
}
|
|
||||||
|
|
||||||
error(message);
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultOptions = {
|
|
||||||
onCompleted: () => {},
|
|
||||||
onError: handleError
|
|
||||||
};
|
|
||||||
|
|
||||||
const call = async (request, options) => {
|
|
||||||
const internalOptions = { ...defaultOptions, ...options };
|
|
||||||
const { onCompleted, onError } = internalOptions;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await request();
|
|
||||||
onCompleted(result);
|
|
||||||
} catch (error) {
|
|
||||||
onError(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSystemVersion = (options = defaultOptions) => {
|
|
||||||
const releaseNotesPromise = call(
|
|
||||||
() => get(`${systemRoute}/version`),
|
|
||||||
options
|
|
||||||
);
|
|
||||||
return releaseNotesPromise;
|
|
||||||
};
|
|
||||||
|
|
||||||
const readReleaseNotes = (options = defaultOptions) => {
|
|
||||||
const releaseNotesPromise = call(
|
|
||||||
() => get(`${systemRoute}/release-notes`),
|
|
||||||
options
|
|
||||||
);
|
|
||||||
return releaseNotesPromise;
|
|
||||||
};
|
|
||||||
|
|
||||||
const readMachines = (options = defaultOptions) => {
|
|
||||||
const machinesPromise = call(
|
|
||||||
() => get(`${networkRoute}/machines`),
|
|
||||||
options
|
|
||||||
);
|
|
||||||
return machinesPromise;
|
|
||||||
};
|
|
||||||
|
|
||||||
const wakeMachine = (machineId, options = defaultOptions) => {
|
|
||||||
const promise = call(
|
|
||||||
() => post(`${powerActionsRoute}/wake`, { machineId }),
|
|
||||||
options
|
|
||||||
);
|
|
||||||
return promise;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pingMachine = (machineId, options = defaultOptions) => {
|
|
||||||
const promise = call(
|
|
||||||
() => post(`${powerActionsRoute}/ping`, { machineId }),
|
|
||||||
options
|
|
||||||
);
|
|
||||||
return promise;
|
|
||||||
};
|
|
||||||
|
|
||||||
const shutdownMachine = (
|
|
||||||
machineId,
|
|
||||||
delay,
|
|
||||||
force,
|
|
||||||
options = defaultOptions
|
|
||||||
) => {
|
|
||||||
const promise = call(
|
|
||||||
() => post(`${powerActionsRoute}/shutdown`, { machineId, delay, force }),
|
|
||||||
options
|
|
||||||
);
|
|
||||||
return promise;
|
|
||||||
};
|
|
||||||
|
|
||||||
const restartMachine = (
|
|
||||||
machineId,
|
|
||||||
delay,
|
|
||||||
force,
|
|
||||||
options = defaultOptions
|
|
||||||
) => {
|
|
||||||
const promise = call(
|
|
||||||
() => post(`${powerActionsRoute}/restart`, { machineId, delay, force }),
|
|
||||||
options
|
|
||||||
);
|
|
||||||
return promise;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
getSystemVersion,
|
|
||||||
readReleaseNotes,
|
|
||||||
readMachines,
|
|
||||||
wakeMachine,
|
|
||||||
pingMachine,
|
|
||||||
shutdownMachine,
|
|
||||||
restartMachine
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useApi;
|
|
|
@ -1,72 +1,14 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import PropTypes from "prop-types";
|
import { UserPermissionsProvider, SensitiveInfoProvider } from "../providers";
|
||||||
import AppLayout from "./layout/AppLayout";
|
import AppLayout from "./layout/AppLayout";
|
||||||
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 App = () => {
|
const App = () => {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter basename={process.env.PUBLIC_URL || ""}>
|
<UserPermissionsProvider>
|
||||||
<Switch>
|
<SensitiveInfoProvider>
|
||||||
<Route exact path="/" render={() => <Redirect to="/dashboard" />} />
|
<AppLayout />
|
||||||
<PublicRoute path="/login" component={LoginContainer} />
|
</SensitiveInfoProvider>
|
||||||
<PrivateRoute path="/" component={AppLayout} />
|
</UserPermissionsProvider>
|
||||||
</Switch>
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -4,7 +4,7 @@ import PageNotFound from "./PageNotFound";
|
||||||
import NetworkContainer from "../../features/network/components/NetworkContainer";
|
import NetworkContainer from "../../features/network/components/NetworkContainer";
|
||||||
import SystemContainer from "../../features/system/SystemContainer";
|
import SystemContainer from "../../features/system/SystemContainer";
|
||||||
import SettingsContainer from "../../features/settings/SettingsContainer";
|
import SettingsContainer from "../../features/settings/SettingsContainer";
|
||||||
import DashboardContainer from "../../features/dashboard/components/DashboardContainer";
|
import DashboardContainer from "../../features/dashboard/DashboardContainer";
|
||||||
import UserProfileContainer from "../../features/user/profile/card/UserProfileContainer";
|
import UserProfileContainer from "../../features/user/profile/card/UserProfileContainer";
|
||||||
import AboutContainer from "../../features/about/AboutContainer";
|
import AboutContainer from "../../features/about/AboutContainer";
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import useApi from "../../../api";
|
|
||||||
import ReleaseNotesList from "./ReleaseNotesList";
|
import ReleaseNotesList from "./ReleaseNotesList";
|
||||||
import TimelineComponent from "../timeline/TimelineComponent";
|
import TimelineComponent from "../timeline/TimelineComponent";
|
||||||
|
import { routes, get } from "../../../utils/api";
|
||||||
|
|
||||||
const sort = releases =>
|
const sort = releases =>
|
||||||
releases.sort((a, b) => new Date(b.date) - new Date(a.date));
|
releases.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||||
|
@ -10,16 +10,12 @@ const sort = releases =>
|
||||||
const ReleaseNotesContainer = ({ view }) => {
|
const ReleaseNotesContainer = ({ view }) => {
|
||||||
const [state, setState] = useState({ data: [], loaded: false });
|
const [state, setState] = useState({ data: [], loaded: false });
|
||||||
|
|
||||||
const api = useApi();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.loaded) return;
|
if (state.loaded) return;
|
||||||
api.readReleaseNotes({
|
get(routes.releaseNotes, {
|
||||||
onCompleted: data => {
|
onCompleted: data => setState({ data, loaded: true })
|
||||||
setState({ data, loaded: true });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, [api, state.loaded]);
|
}, [state.loaded]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -1,20 +1,16 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import SystemVersionComponent from "./SystemVersionComponent";
|
import SystemVersionComponent from "./SystemVersionComponent";
|
||||||
import useApi from "../../../api";
|
import { routes, get } from "../../../utils/api";
|
||||||
|
|
||||||
const SystemVersionContainer = () => {
|
const SystemVersionContainer = () => {
|
||||||
const [state, setState] = useState({ data: {}, loaded: false });
|
const [state, setState] = useState({ data: {}, loaded: false });
|
||||||
|
|
||||||
const api = useApi();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.loaded) return;
|
if (state.loaded) return;
|
||||||
api.getSystemVersion({
|
get(routes.systemVersion, {
|
||||||
onCompleted: data => {
|
onCompleted: data => setState({ data, loaded: true })
|
||||||
setState({ data, loaded: true });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, [api, state.loaded]);
|
}, [state.loaded]);
|
||||||
|
|
||||||
return <>{state.loaded && <SystemVersionComponent data={state.data} />}</>;
|
return <>{state.loaded && <SystemVersionComponent data={state.data} />}</>;
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
import React from "react";
|
||||||
|
import AnnouncementsSection from "./announcements/AnnouncementsSection";
|
||||||
|
|
||||||
|
const DashboardContainer = () => (
|
||||||
|
<>
|
||||||
|
<AnnouncementsSection />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default DashboardContainer;
|
|
@ -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;
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,5 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
const DashboardContainer = () => <h2>In development...</h2>;
|
|
||||||
|
|
||||||
export default DashboardContainer;
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
const styles = theme => ({
|
||||||
|
alert: {
|
||||||
|
width: "100%",
|
||||||
|
"& > * + *": {
|
||||||
|
marginTop: theme.spacing(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default styles;
|
|
@ -8,7 +8,7 @@ import {
|
||||||
} from "@material-ui/core";
|
} from "@material-ui/core";
|
||||||
import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
|
import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
|
||||||
import withStyles from "@material-ui/core/styles/withStyles";
|
import withStyles from "@material-ui/core/styles/withStyles";
|
||||||
import MachineLog from "./common/MachineLog";
|
import MachineCollapsedContent from "./common/MachineCollapsedContent";
|
||||||
import { DataLabel } from "../../../components/common";
|
import { DataLabel } from "../../../components/common";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useSensitiveInfo } from "../../../hooks";
|
import { useSensitiveInfo } from "../../../hooks";
|
||||||
|
@ -87,7 +87,11 @@ const MachineAccordion = ({ machine, actions, logs, addLog }) => {
|
||||||
</Grid>
|
</Grid>
|
||||||
</IconLeftAccordionSummary>
|
</IconLeftAccordionSummary>
|
||||||
<AccordionDetails>
|
<AccordionDetails>
|
||||||
<MachineLog logs={logs} />
|
<MachineCollapsedContent
|
||||||
|
description={machine.description}
|
||||||
|
logs={logs}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { ViewModes } from "./ViewModeSelection";
|
||||||
import { useToast } from "../../../hooks";
|
import { useToast } from "../../../hooks";
|
||||||
import { LastPage, RotateLeft, Launch, Stop } from "@material-ui/icons";
|
import { LastPage, RotateLeft, Launch, Stop } from "@material-ui/icons";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useApi from "../../../api";
|
import { routes, post } from "../../../utils/api";
|
||||||
|
|
||||||
const MachineContainer = ({ machine, viewMode }) => {
|
const MachineContainer = ({ machine, viewMode }) => {
|
||||||
const [logs, setLogs] = useState([]);
|
const [logs, setLogs] = useState([]);
|
||||||
|
@ -14,8 +14,6 @@ const MachineContainer = ({ machine, viewMode }) => {
|
||||||
const { success, error } = useToast();
|
const { success, error } = useToast();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const api = useApi();
|
|
||||||
|
|
||||||
const addLog = useCallback(
|
const addLog = useCallback(
|
||||||
text => {
|
text => {
|
||||||
setLogs(prev => [...prev, text]);
|
setLogs(prev => [...prev, text]);
|
||||||
|
@ -37,29 +35,41 @@ const MachineContainer = ({ machine, viewMode }) => {
|
||||||
|
|
||||||
const pingMachine = useCallback(
|
const pingMachine = useCallback(
|
||||||
async machine => {
|
async machine => {
|
||||||
await api.pingMachine(machine.machineId, {
|
await post(
|
||||||
onCompleted: manageActionResponse
|
routes.pingMachine,
|
||||||
});
|
{ machineId: machine.machineId },
|
||||||
|
{
|
||||||
|
onCompleted: manageActionResponse
|
||||||
|
}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[manageActionResponse, api]
|
[manageActionResponse]
|
||||||
);
|
);
|
||||||
|
|
||||||
const shutdownMachine = useCallback(
|
const shutdownMachine = useCallback(
|
||||||
async machine => {
|
async machine => {
|
||||||
await api.shutdownMachine(machine.machineId, 0, false, {
|
await post(
|
||||||
onCompleted: manageActionResponse
|
routes.shutdownMachine,
|
||||||
});
|
{ machineId: machine.machineId, delay: 0, force: false },
|
||||||
|
{
|
||||||
|
onCompleted: manageActionResponse
|
||||||
|
}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[manageActionResponse, api]
|
[manageActionResponse]
|
||||||
);
|
);
|
||||||
|
|
||||||
const restartMachine = useCallback(
|
const restartMachine = useCallback(
|
||||||
async machine => {
|
async machine => {
|
||||||
await api.restartMachine(machine.machineId, 0, false, {
|
await post(
|
||||||
onCompleted: manageActionResponse
|
routes.restartMachine,
|
||||||
});
|
{ machineId: machine.machineId, delay: 0, force: false },
|
||||||
|
{
|
||||||
|
onCompleted: manageActionResponse
|
||||||
|
}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[manageActionResponse, api]
|
[manageActionResponse]
|
||||||
);
|
);
|
||||||
|
|
||||||
const actions = [
|
const actions = [
|
||||||
|
|
|
@ -3,7 +3,7 @@ import PropTypes from "prop-types";
|
||||||
import { TableCell, TableRow, IconButton, Collapse } from "@material-ui/core";
|
import { TableCell, TableRow, IconButton, Collapse } from "@material-ui/core";
|
||||||
import { KeyboardArrowDown, KeyboardArrowUp } from "@material-ui/icons";
|
import { KeyboardArrowDown, KeyboardArrowUp } from "@material-ui/icons";
|
||||||
import { makeStyles } from "@material-ui/core/styles";
|
import { makeStyles } from "@material-ui/core/styles";
|
||||||
import MachineLog from "./common/MachineLog";
|
import MachineCollapsedContent from "./common/MachineCollapsedContent";
|
||||||
import { useSensitiveInfo } from "../../../hooks";
|
import { useSensitiveInfo } from "../../../hooks";
|
||||||
import ActionsGroup from "./common/ActionsGroup";
|
import ActionsGroup from "./common/ActionsGroup";
|
||||||
|
|
||||||
|
@ -45,7 +45,11 @@ const MachineTableRow = ({ machine, actions, logs, addLog }) => {
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={6}>
|
<TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={6}>
|
||||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||||
<MachineLog logs={logs} />
|
<MachineCollapsedContent
|
||||||
|
description={machine.description}
|
||||||
|
logs={logs}
|
||||||
|
style={{ paddingBottom: "10px" }}
|
||||||
|
/>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
|
@ -3,11 +3,11 @@ import {
|
||||||
NetworkStateContext,
|
NetworkStateContext,
|
||||||
NetworkDispatchContext
|
NetworkDispatchContext
|
||||||
} from "../../network/state/contexts";
|
} from "../../network/state/contexts";
|
||||||
import useApi from "../../../api";
|
|
||||||
import MachinesListComponent from "./MachinesListComponent";
|
import MachinesListComponent from "./MachinesListComponent";
|
||||||
import PageTitle from "../../../components/common/PageTitle";
|
import PageTitle from "../../../components/common/PageTitle";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import ViewModeSelection, { ViewModes } from "./ViewModeSelection";
|
import ViewModeSelection, { ViewModes } from "./ViewModeSelection";
|
||||||
|
import { routes, get } from "../../../utils/api";
|
||||||
|
|
||||||
const MachinesContainer = () => {
|
const MachinesContainer = () => {
|
||||||
const [viewMode, setViewMode] = useState(null);
|
const [viewMode, setViewMode] = useState(null);
|
||||||
|
@ -16,16 +16,14 @@ const MachinesContainer = () => {
|
||||||
const dispatchActions = useContext(NetworkDispatchContext);
|
const dispatchActions = useContext(NetworkDispatchContext);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const api = useApi();
|
|
||||||
|
|
||||||
const handleReadMachines = useCallback(async () => {
|
const handleReadMachines = useCallback(async () => {
|
||||||
await api.readMachines({
|
await get(routes.machines, {
|
||||||
onCompleted: machines => {
|
onCompleted: machines => {
|
||||||
const data = Object.assign(machines, { loaded: true });
|
const data = Object.assign(machines, { loaded: true });
|
||||||
dispatchActions.onNetworkChange("machines", data);
|
dispatchActions.onNetworkChange("machines", data);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [dispatchActions, api]);
|
}, [dispatchActions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.network.machines.loaded) {
|
if (!state.network.machines.loaded) {
|
||||||
|
|
|
@ -3,7 +3,7 @@ import PropTypes from "prop-types";
|
||||||
import { IconButton, Tooltip } from "@material-ui/core";
|
import { IconButton, Tooltip } from "@material-ui/core";
|
||||||
|
|
||||||
const ActionButton = React.forwardRef((props, _ref) => {
|
const ActionButton = React.forwardRef((props, _ref) => {
|
||||||
const { action, machine, callback } = props;
|
const { action, machine, callback, disabled } = props;
|
||||||
const id = `machine-item-${machine.machineId}-${action.code}`;
|
const id = `machine-item-${machine.machineId}-${action.code}`;
|
||||||
const handleActionClick = event => {
|
const handleActionClick = event => {
|
||||||
action.effect(machine, event);
|
action.effect(machine, event);
|
||||||
|
@ -22,6 +22,7 @@ const ActionButton = React.forwardRef((props, _ref) => {
|
||||||
size={"small"}
|
size={"small"}
|
||||||
onFocus={event => event.stopPropagation()}
|
onFocus={event => event.stopPropagation()}
|
||||||
onClick={handleActionClick}
|
onClick={handleActionClick}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<action.icon />
|
<action.icon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
@ -39,7 +40,8 @@ ActionButton.propTypes = {
|
||||||
tooltip: PropTypes.string.isRequired,
|
tooltip: PropTypes.string.isRequired,
|
||||||
effect: PropTypes.func.isRequired
|
effect: PropTypes.func.isRequired
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
callback: PropTypes.func
|
callback: PropTypes.func,
|
||||||
|
disabled: PropTypes.bool
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ActionButton;
|
export default ActionButton;
|
||||||
|
|
|
@ -5,11 +5,13 @@ import ActionButton from "./ActionButton";
|
||||||
import { Menu } from "@material-ui/core";
|
import { Menu } from "@material-ui/core";
|
||||||
import { MoreHoriz } from "@material-ui/icons";
|
import { MoreHoriz } from "@material-ui/icons";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { usePermissions } from "../../../../hooks";
|
||||||
|
|
||||||
const ActionsGroup = ({ machine, actions, addLog }) => {
|
const ActionsGroup = ({ machine, actions, addLog }) => {
|
||||||
const [menuAnchor, setMenuAnchor] = useState(null);
|
const [menuAnchor, setMenuAnchor] = useState(null);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { operateMachines: canOperateMachines } = usePermissions();
|
||||||
|
|
||||||
const mainActions = useMemo(
|
const mainActions = useMemo(
|
||||||
() => actions.filter(a => a.main === true),
|
() => actions.filter(a => a.main === true),
|
||||||
|
@ -31,12 +33,17 @@ const ActionsGroup = ({ machine, actions, addLog }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<WakeComponent machine={machine} addLog={addLog} />
|
<WakeComponent
|
||||||
|
machine={machine}
|
||||||
|
addLog={addLog}
|
||||||
|
disabled={!canOperateMachines}
|
||||||
|
/>
|
||||||
{mainActions.map(action => (
|
{mainActions.map(action => (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
key={`machine-item-${machine.machineId}-${action.code}`}
|
key={`machine-item-${machine.machineId}-${action.code}`}
|
||||||
action={action}
|
action={action}
|
||||||
machine={machine}
|
machine={machine}
|
||||||
|
disabled={!canOperateMachines}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
@ -62,6 +69,7 @@ const ActionsGroup = ({ machine, actions, addLog }) => {
|
||||||
action={action}
|
action={action}
|
||||||
machine={machine}
|
machine={machine}
|
||||||
callback={handleMenuClose}
|
callback={handleMenuClose}
|
||||||
|
disabled={!canOperateMachines}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
|
@ -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;
|
|
@ -13,7 +13,7 @@ const MachineLog = ({ logs }) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box width="100%">
|
<Box>
|
||||||
<div style={{ height: 200 }}>
|
<div style={{ height: 200 }}>
|
||||||
<ScrollFollow
|
<ScrollFollow
|
||||||
startFollowing={true}
|
startFollowing={true}
|
||||||
|
|
|
@ -5,19 +5,18 @@ import { PowerSettingsNew } from "@material-ui/icons";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useToast } from "../../../../hooks";
|
import { useToast } from "../../../../hooks";
|
||||||
import { msToMinAndSec } from "../../../../utils/time";
|
import { msToMinAndSec } from "../../../../utils/time";
|
||||||
import useApi from "../../../../api";
|
import { routes, post } from "../../../../utils/api";
|
||||||
|
|
||||||
const initialState = { on: false };
|
const initialState = { on: false };
|
||||||
const defaultPingInterval = 1200000; //20 minutes
|
const defaultPingInterval = 1200000; //20 minutes
|
||||||
const defaultStartingTime = 300000; //5 minutes
|
const defaultStartingTime = 300000; //5 minutes
|
||||||
|
|
||||||
const WakeComponent = ({ machine, addLog }) => {
|
const WakeComponent = ({ machine, addLog, disabled }) => {
|
||||||
const [state, setState] = useState(initialState);
|
const [state, setState] = useState(initialState);
|
||||||
const [trigger, setTrigger] = useState(false);
|
const [trigger, setTrigger] = useState(false);
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { success, error } = useToast();
|
const { success, error } = useToast();
|
||||||
const api = useApi();
|
|
||||||
|
|
||||||
const pingInterval =
|
const pingInterval =
|
||||||
process.env.REACT_APP_MACHINE_PING_INTERVAL || defaultPingInterval;
|
process.env.REACT_APP_MACHINE_PING_INTERVAL || defaultPingInterval;
|
||||||
|
@ -38,45 +37,53 @@ const WakeComponent = ({ machine, addLog }) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const wakeMachine = useCallback(async () => {
|
const wakeMachine = useCallback(async () => {
|
||||||
await api.wakeMachine(machine.machineId, {
|
await post(
|
||||||
onCompleted: result => {
|
routes.wakeMachine,
|
||||||
setState(prev => ({ ...prev, on: result.success }));
|
{ machineId: machine.machineId },
|
||||||
log(`[Wake]: Success: ${result.success}. Status: ${result.status}`);
|
{
|
||||||
if (result.success) {
|
onCompleted: result => {
|
||||||
success(result.status);
|
setState(prev => ({ ...prev, on: result.success }));
|
||||||
|
log(`[Wake]: Success: ${result.success}. Status: ${result.status}`);
|
||||||
|
if (result.success) {
|
||||||
|
success(result.status);
|
||||||
|
|
||||||
//retrigger
|
//retrigger
|
||||||
log(
|
log(
|
||||||
`Periodic ping will be re-triggered in ${startingTime} ms [${msToMinAndSec(
|
`Periodic ping will be re-triggered in ${startingTime} ms [${msToMinAndSec(
|
||||||
startingTime
|
startingTime
|
||||||
)}]`
|
)}]`
|
||||||
);
|
);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setTrigger(prev => !prev);
|
setTrigger(prev => !prev);
|
||||||
}, startingTime);
|
}, startingTime);
|
||||||
} else {
|
} else {
|
||||||
error(result.status);
|
error(result.status);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
}, [log, success, error, startingTime, machine.machineId, api]);
|
}, [log, success, error, startingTime, machine.machineId]);
|
||||||
|
|
||||||
const pingInLoop = useCallback(async () => {
|
const pingInLoop = useCallback(async () => {
|
||||||
await api.pingMachine(machine.machineId, {
|
if (disabled) return;
|
||||||
onCompleted: result => {
|
await post(
|
||||||
setState(prev => ({ ...prev, on: result.success }));
|
routes.pingMachine,
|
||||||
log(`[Ping]: Success: ${result.success}. Status: ${result.status}`);
|
{ machineId: machine.machineId },
|
||||||
|
{
|
||||||
|
onCompleted: result => {
|
||||||
|
setState(prev => ({ ...prev, on: result.success }));
|
||||||
|
log(`[Ping]: Success: ${result.success}. Status: ${result.status}`);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setTrigger(prev => !prev);
|
setTrigger(prev => !prev);
|
||||||
}, pingInterval);
|
}, pingInterval);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => {}
|
onError: () => {}
|
||||||
});
|
}
|
||||||
}, [machine, log, pingInterval]); // eslint-disable-line react-hooks/exhaustive-deps
|
);
|
||||||
// if "api" is added in pingInLoop dependencies, the useEffect is triggered in loop
|
}, [machine, log, pingInterval, disabled]);
|
||||||
|
|
||||||
useEffect(pingInLoop, [trigger, pingInLoop]);
|
useEffect(pingInLoop, [trigger, pingInLoop]);
|
||||||
|
|
||||||
|
@ -91,9 +98,9 @@ const WakeComponent = ({ machine, addLog }) => {
|
||||||
<IconButton
|
<IconButton
|
||||||
id={`machine-${machine.machineId}-wake`}
|
id={`machine-${machine.machineId}-wake`}
|
||||||
size={"small"}
|
size={"small"}
|
||||||
disabled={state.on}
|
disabled={disabled || state.on}
|
||||||
onClick={handleWakeClick}
|
onClick={handleWakeClick}
|
||||||
style={state.on ? { color: "#33cc33" } : {}}
|
style={state.on ? { color: "#33cc33" } : undefined}
|
||||||
onFocus={event => event.stopPropagation()}
|
onFocus={event => event.stopPropagation()}
|
||||||
>
|
>
|
||||||
<PowerSettingsNew />
|
<PowerSettingsNew />
|
||||||
|
@ -105,7 +112,8 @@ const WakeComponent = ({ machine, addLog }) => {
|
||||||
|
|
||||||
WakeComponent.propTypes = {
|
WakeComponent.propTypes = {
|
||||||
machine: PropTypes.object.isRequired,
|
machine: PropTypes.object.isRequired,
|
||||||
addLog: PropTypes.func.isRequired
|
addLog: PropTypes.func.isRequired,
|
||||||
|
disabled: PropTypes.bool
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WakeComponent;
|
export default WakeComponent;
|
||||||
|
|
|
@ -1,8 +1,15 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import MachinesContainer from "../../machines/components/MachinesContainer";
|
import MachinesContainer from "../../machines/components/MachinesContainer";
|
||||||
import NetworkStateProvider from "../state/NetworkStateProvider";
|
import NetworkStateProvider from "../state/NetworkStateProvider";
|
||||||
|
import { usePermissions } from "../../../hooks";
|
||||||
|
import NotAllowed from "../../../components/common/NotAllowed";
|
||||||
|
|
||||||
const NetworkContainer = () => {
|
const NetworkContainer = () => {
|
||||||
|
const { loading, viewMachines } = usePermissions();
|
||||||
|
|
||||||
|
if (loading) return "";
|
||||||
|
if (!viewMachines) return <NotAllowed />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NetworkStateProvider>
|
<NetworkStateProvider>
|
||||||
<MachinesContainer />
|
<MachinesContainer />
|
||||||
|
|
|
@ -4,9 +4,15 @@ import { useTranslation } from "react-i18next";
|
||||||
import { Card, CardHeader, CardContent } from "@material-ui/core";
|
import { Card, CardHeader, CardContent } from "@material-ui/core";
|
||||||
import PageTitle from "../../../../components/common/PageTitle";
|
import PageTitle from "../../../../components/common/PageTitle";
|
||||||
import UserProfileCardContent from "./UserProfileCardContent";
|
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 UserProfileComponent = ({ userData }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const classes = useStyles();
|
||||||
|
|
||||||
const userLoginDate = useMemo(
|
const userLoginDate = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
@ -32,6 +38,13 @@ const UserProfileComponent = ({ userData }) => {
|
||||||
<UserProfileCardContent userData={userData} />
|
<UserProfileCardContent userData={userData} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<div className={classes.section}>
|
||||||
|
<SecurityComponent
|
||||||
|
userGroups={userData.userGroups}
|
||||||
|
userRoles={userData.userRoles}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -36,11 +36,11 @@ const ContactOption = ({ tooltip, label, link, onClick, ...props }) => {
|
||||||
primary={
|
primary={
|
||||||
<Tooltip title={tooltip}>
|
<Tooltip title={tooltip}>
|
||||||
{onClick ? (
|
{onClick ? (
|
||||||
<Link href="#" onClick={onClick}>
|
<Link href="#" onClick={onClick} style={{ fontWeight: "bold" }}>
|
||||||
{linkLabel}
|
{linkLabel}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<Link href={link} target="_blank">
|
<Link href={link} target="_blank" style={{ fontWeight: "bold" }}>
|
||||||
{linkLabel}
|
{linkLabel}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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;
|
|
@ -1,5 +1,8 @@
|
||||||
const style = theme => {
|
const style = theme => {
|
||||||
return {
|
return {
|
||||||
|
section: {
|
||||||
|
marginTop: theme.spacing(2)
|
||||||
|
},
|
||||||
panel: {
|
panel: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
|
@ -12,6 +15,12 @@ const style = theme => {
|
||||||
display: "block",
|
display: "block",
|
||||||
width: theme.spacing(25),
|
width: theme.spacing(25),
|
||||||
height: theme.spacing(25)
|
height: theme.spacing(25)
|
||||||
|
},
|
||||||
|
paper: {
|
||||||
|
margin: theme.spacing(2)
|
||||||
|
},
|
||||||
|
chip: {
|
||||||
|
margin: theme.spacing(0.5)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { useToast } from "./useToast";
|
import { useToast } from "./useToast";
|
||||||
import { useSensitiveInfo } from "../providers/SensitiveInfoProvider";
|
import { useSensitiveInfo } from "../providers/SensitiveInfoProvider";
|
||||||
|
import { usePermissions } from "../providers/UserPermissionsProvider";
|
||||||
import { useClipboard } from "./useClipboard";
|
import { useClipboard } from "./useClipboard";
|
||||||
|
|
||||||
export { useToast, useSensitiveInfo, useClipboard };
|
export { useToast, useSensitiveInfo, usePermissions, useClipboard };
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
export const useToast = () => {
|
const info = message => toast.info(message);
|
||||||
const info = message => toast.info(message);
|
const success = message => toast.success(message);
|
||||||
const success = message => toast.success(message);
|
const warning = message => toast.warning(message);
|
||||||
const warning = message => toast.warning(message);
|
const error = message => toast.error(message);
|
||||||
const error = message => toast.error(message);
|
const dark = message => toast.dark(message);
|
||||||
const dark = message => toast.dark(message);
|
|
||||||
|
|
||||||
|
export const useToast = () => {
|
||||||
return { info, success, warning, error, dark };
|
return { info, success, warning, error, dark };
|
||||||
};
|
};
|
||||||
|
|
17
src/index.js
17
src/index.js
|
@ -2,23 +2,20 @@ import React, { Suspense } from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import ThemeProvider from "./providers/ThemeProvider";
|
import ThemeProvider from "./providers/ThemeProvider";
|
||||||
import CssBaseline from "@material-ui/core/CssBaseline";
|
import CssBaseline from "@material-ui/core/CssBaseline";
|
||||||
import App from "./components/App";
|
import AppRouter from "./components/AppRouter";
|
||||||
import { TuitioProvider } from "@flare/tuitio-client-react";
|
import { TuitioProvider } from "@flare/tuitio-client-react";
|
||||||
import ToastProvider from "./providers/ToastProvider";
|
import { ToastProvider } from "./providers";
|
||||||
import SensitiveInfoProvider from "./providers/SensitiveInfoProvider";
|
|
||||||
import "./utils/i18n";
|
import "./utils/i18n";
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<TuitioProvider tuitioUrl={process.env.REACT_APP_TUITIO_URL}>
|
<TuitioProvider tuitioUrl={process.env.REACT_APP_TUITIO_URL}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<SensitiveInfoProvider>
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<ToastProvider>
|
||||||
<ToastProvider>
|
<AppRouter />
|
||||||
<App />
|
</ToastProvider>
|
||||||
</ToastProvider>
|
</Suspense>
|
||||||
</Suspense>
|
|
||||||
</SensitiveInfoProvider>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</TuitioProvider>,
|
</TuitioProvider>,
|
||||||
document.getElementById("root")
|
document.getElementById("root")
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
import React, { useState, useEffect, useContext, useMemo } from "react";
|
||||||
|
import PropTypes from "prop-types";
|
||||||
|
import { routes, get } from "../utils/api";
|
||||||
|
|
||||||
|
const permissionCodes = {
|
||||||
|
VIEW_DASHBOARD: "VIEW_DASHBOARD",
|
||||||
|
MANAGE_USERS: "MANAGE_USERS",
|
||||||
|
MANAGE_SETTINGS: "MANAGE_SETTINGS",
|
||||||
|
VIEW_MACHINES: "VIEW_MACHINES",
|
||||||
|
MANAGE_MACHINES: "MANAGE_MACHINES",
|
||||||
|
OPERATE_MACHINES: "OPERATE_MACHINES",
|
||||||
|
GUEST_ACCESS: "GUEST_ACCESS"
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
permissions: [],
|
||||||
|
loading: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPermissionFlags = permissions => {
|
||||||
|
const viewDashboard =
|
||||||
|
permissions.includes(permissionCodes.VIEW_DASHBOARD) ?? false;
|
||||||
|
const manageUsers =
|
||||||
|
permissions.includes(permissionCodes.MANAGE_USERS) ?? false;
|
||||||
|
const manageSettings =
|
||||||
|
permissions.includes(permissionCodes.MANAGE_SETTINGS) ?? false;
|
||||||
|
const viewMachines =
|
||||||
|
permissions.includes(permissionCodes.VIEW_MACHINES) ?? false;
|
||||||
|
const manageMachines =
|
||||||
|
permissions.includes(permissionCodes.MANAGE_MACHINES) ?? false;
|
||||||
|
const operateMachines =
|
||||||
|
permissions.includes(permissionCodes.OPERATE_MACHINES) ?? false;
|
||||||
|
const guestAccess =
|
||||||
|
permissions.includes(permissionCodes.GUEST_ACCESS) ?? false;
|
||||||
|
|
||||||
|
const flags = {
|
||||||
|
viewDashboard,
|
||||||
|
manageUsers,
|
||||||
|
manageSettings,
|
||||||
|
viewMachines,
|
||||||
|
manageMachines,
|
||||||
|
operateMachines,
|
||||||
|
guestAccess
|
||||||
|
};
|
||||||
|
|
||||||
|
const isGuest = guestAccess === true;
|
||||||
|
const isUser = Object.values(flags).includes(true) && !flags.guestAccess;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...flags,
|
||||||
|
isGuest,
|
||||||
|
isUser
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserPermissionsContext = React.createContext(initialState);
|
||||||
|
|
||||||
|
const usePermissions = () => {
|
||||||
|
const { permissions, loading } = useContext(UserPermissionsContext);
|
||||||
|
const flags = useMemo(() => getPermissionFlags(permissions), [permissions]);
|
||||||
|
return { loading, ...flags };
|
||||||
|
};
|
||||||
|
|
||||||
|
const UserPermissionsProvider = ({ children }) => {
|
||||||
|
const [permissions, setPermissions] = useState(initialState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
get(routes.permissions, {
|
||||||
|
onCompleted: data => setPermissions({ ...data, loading: false })
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UserPermissionsContext.Provider value={permissions}>
|
||||||
|
{children}
|
||||||
|
</UserPermissionsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
UserPermissionsProvider.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export { UserPermissionsProvider, usePermissions };
|
||||||
|
export default UserPermissionsProvider;
|
|
@ -0,0 +1,11 @@
|
||||||
|
import ThemeProvider from "./ThemeProvider";
|
||||||
|
import ToastProvider from "./ToastProvider";
|
||||||
|
import SensitiveInfoProvider from "./SensitiveInfoProvider";
|
||||||
|
import UserPermissionsProvider from "./UserPermissionsProvider";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ThemeProvider,
|
||||||
|
ToastProvider,
|
||||||
|
SensitiveInfoProvider,
|
||||||
|
UserPermissionsProvider
|
||||||
|
};
|
|
@ -0,0 +1,65 @@
|
||||||
|
import * as axios from "../utils/axios";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
|
||||||
|
const networkRoute = `${process.env.REACT_APP_NETWORK_RESURRECTOR_API_URL}/network`;
|
||||||
|
const systemRoute = `${process.env.REACT_APP_NETWORK_RESURRECTOR_API_URL}/system`;
|
||||||
|
const powerActionsRoute = `${process.env.REACT_APP_NETWORK_RESURRECTOR_API_URL}/resurrector`;
|
||||||
|
const securityRoute = `${process.env.REACT_APP_NETWORK_RESURRECTOR_API_URL}/security`;
|
||||||
|
|
||||||
|
const routes = {
|
||||||
|
permissions: `${securityRoute}/permissions`,
|
||||||
|
systemVersion: `${systemRoute}/version`,
|
||||||
|
releaseNotes: `${systemRoute}/release-notes`,
|
||||||
|
machines: `${networkRoute}/machines`,
|
||||||
|
wakeMachine: `${powerActionsRoute}/wake`,
|
||||||
|
pingMachine: `${powerActionsRoute}/ping`,
|
||||||
|
shutdownMachine: `${powerActionsRoute}/shutdown`,
|
||||||
|
restartMachine: `${powerActionsRoute}/restart`
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleError = err => {
|
||||||
|
let message;
|
||||||
|
switch (err?.status) {
|
||||||
|
case 500:
|
||||||
|
message = err.title;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 404:
|
||||||
|
message = err.message;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
message = err.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultOptions = {
|
||||||
|
onCompleted: () => {},
|
||||||
|
onError: handleError
|
||||||
|
};
|
||||||
|
|
||||||
|
const call = async (request, options = defaultOptions) => {
|
||||||
|
const internalOptions = { ...defaultOptions, ...options };
|
||||||
|
const { onCompleted, onError } = internalOptions;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await request();
|
||||||
|
onCompleted(result);
|
||||||
|
} catch (error) {
|
||||||
|
onError(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const get = (route, options) => {
|
||||||
|
const promise = call(() => axios.get(route), options);
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const post = (route, data, options) => {
|
||||||
|
const promise = call(() => axios.post(route, data), options);
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { routes, get, post };
|
Loading…
Reference in New Issue