Integrated Salidomo product in both backend and frontend

This commit is contained in:
Noe 2024-04-16 13:57:04 +02:00
parent 41917db9be
commit 2c9a530415
25 changed files with 1595 additions and 114 deletions

View File

@ -281,11 +281,24 @@ public class Controller : ControllerBase
return Unauthorized();
return user
.AccessibleInstallations()
.AccessibleInstallations(product:0)
.Select(i => i.FillOrderNumbers().HideParentIfUserHasNoAccessToParent(user).HideWriteKeyIfUserIsNotAdmin(user.UserType))
.ToList();
}
[HttpGet(nameof(GetAllSalidomoInstallations))]
public ActionResult<IEnumerable<Installation>> GetAllSalidomoInstallations(Token authToken)
{
var user = Db.GetSession(authToken)?.User;
if (user is null)
return Unauthorized();
return user
.AccessibleInstallations(product:1)
.ToList();
}
[HttpGet(nameof(GetAllFolders))]
@ -309,7 +322,7 @@ public class Controller : ControllerBase
return Unauthorized();
var foldersAndInstallations = user
.AccessibleFoldersAndInstallations()
.AccessibleFoldersAndInstallations(product:0)
.Do(o => o.FillOrderNumbers())
.Select(o => o.HideParentIfUserHasNoAccessToParent(user))
.OfType<Object>(); // Important! JSON serializer must see Objects otherwise
@ -342,6 +355,7 @@ public class Controller : ControllerBase
[HttpPost(nameof(CreateInstallation))]
public async Task<ActionResult<Installation>> CreateInstallation([FromBody] Installation installation, Token authToken)
{
var session = Db.GetSession(authToken);
if (! await session.Create(installation))
@ -453,9 +467,14 @@ public class Controller : ControllerBase
if (!session.Update(installation))
return Unauthorized();
if (installation.Product == 0)
{
return installation.FillOrderNumbers().HideParentIfUserHasNoAccessToParent(session!.User).HideWriteKeyIfUserIsNotAdmin(session.User.UserType);
}
return installation.HideParentIfUserHasNoAccessToParent(session!.User);
}
[HttpPost(nameof(AcknowledgeError))]
public ActionResult AcknowledgeError(Int64 id, Token authToken)
{

View File

@ -7,16 +7,8 @@ public class Installation : TreeNode
public String Location { get; set; } = "";
public String Region { get; set; } = "";
public String Country { get; set; } = "";
public String InstallationName { get; set; } = "";
public String VpnIp { get; set; } = "";
// TODO: make relation
//public IReadOnlyList<String> OrderNumbers { get; set; } = Array.Empty<String>();
// public String? OrderNumbers { get; set; } = "";
public Double Lat { get; set; }
public Double Long { get; set; }
public String S3Region { get; set; } = "sos-ch-dk-2";
public String S3Provider { get; set; } = "exo.io";
public String S3WriteKey { get; set; } = "";
@ -26,9 +18,10 @@ public class Installation : TreeNode
public String ReadRoleId { get; set; } = "";
public String WriteRoleId { get; set; } = "";
public int Product { get; set; } = 0;
[Ignore]
public String OrderNumbers { get; set; }
public String VrmLink { get; set; } = "";
}

View File

@ -81,6 +81,7 @@ public static class ExoCmd
{
var readRoleId = installation.ReadRoleId;
if (String.IsNullOrEmpty(readRoleId)
||! await CheckRoleExists(readRoleId))
{
@ -117,7 +118,6 @@ public static class ExoCmd
var unixtime = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + 60;
var authheader = "credential=" + S3Credentials.Key + ",expires=" + unixtime + ",signature=" +
BuildSignature("POST", method, contentString, unixtime);
@ -165,7 +165,6 @@ public static class ExoCmd
""";
var unixtime = DateTimeOffset.UtcNow.ToUnixTimeSeconds()+60;
var authheader = "credential="+S3Credentials.Key+",signed-query-args="+",expires="+unixtime+",signature="+BuildSignature("POST", method, contentString, unixtime);
var client = new HttpClient();
@ -294,6 +293,7 @@ public static class ExoCmd
return await s3Region.PutBucket(installation.BucketName()) != null;
}
public static async Task<Boolean> SendConfig(this Installation installation, Configuration config)
{
@ -316,7 +316,7 @@ public static class ExoCmd
// return result.ExitCode == 200;
var maxRetransmissions = 2;
var maxRetransmissions = 4;
UdpClient udpClient = new UdpClient();
udpClient.Client.ReceiveTimeout = 2000;
int port = 9000;

View File

@ -53,6 +53,7 @@ public static class FolderMethods
.Where(f => f.ParentId == parent.Id);
}
public static IEnumerable<Folder> DescendantFolders(this Folder parent)
{
return parent

View File

@ -1,9 +1,5 @@
using System.Net;
using System.Text.Json.Nodes;
using CliWrap;
using InnovEnergy.App.Backend.Database;
using InnovEnergy.App.Backend.Relations;
using InnovEnergy.Lib.S3Utils;
using InnovEnergy.Lib.Utils;
namespace InnovEnergy.App.Backend.DataTypes.Methods;
@ -12,20 +8,29 @@ namespace InnovEnergy.App.Backend.DataTypes.Methods;
public static class InstallationMethods
{
private static readonly String BucketNameSalt =
Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == ""
? "stage"
:"3e5b3069-214a-43ee-8d85-57d72000c19d";
// Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == ""
// ? "stage" :"3e5b3069-214a-43ee-8d85-57d72000c19d";
"3e5b3069-214a-43ee-8d85-57d72000c19d";
private static readonly String SalidomoBucketNameSalt = "c0436b6a-d276-4cd8-9c44-1eae86cf5d0e";
public static String BucketName(this Installation installation)
{
if (installation.Product == 0)
{
return $"{installation.Id}-{BucketNameSalt}";
}
return $"{installation.Id}-{SalidomoBucketNameSalt}";
}
public static async Task<Boolean> RenewS3Credentials(this Installation installation)
{
if(!installation.S3Key.IsNullOrEmpty())
await installation.RevokeReadKey();
var (key,secret) = await installation.CreateReadKey();
installation.S3Key = key;

View File

@ -118,25 +118,41 @@ public static class SessionMethods
{
var user = session?.User;
//Salimax installation
if (installation.Product==0)
{
return user is not null
&& installation is not null
&& user.UserType != 0
&& user.HasAccessToParentOf(installation)
&& Db.Create(installation) // TODO: these two in a transaction
&& installation.SetOrderNumbers()
&& Db.Create(new InstallationAccess { UserId = user.Id, InstallationId = installation.Id })
&& await installation.CreateBucket()
&& await installation.RenewS3Credentials(); // generation of access _after_ generation of
// bucket to prevent "zombie" access-rights.
// This might ** us over if the creation of access rights fails,
// as bucket-names are unique and bound to the installation id... -K
&& await installation.RenewS3Credentials();
}
if (installation.Product==1)
{
return user is not null
&& user.UserType != 0
&& user.HasAccessToParentOf(installation)
&& Db.Create(installation)
&& await installation.CreateBucket()
&& await installation.RenewS3Credentials();
}
return false;
}
public static Boolean Update(this Session? session, Installation? installation)
{
var user = session?.User;
var original = Db.GetInstallationById(installation?.Id);
//Salimax installation
if (installation.Product==0)
{
return user is not null
&& installation is not null
@ -149,16 +165,33 @@ public static class SessionMethods
.Apply(Db.Update);
}
if (installation.Product==1)
{
return user is not null
&& installation is not null
&& original is not null
&& user.UserType !=0
&& user.HasAccessToParentOf(installation)
&& installation
.Apply(Db.Update);
}
return false;
}
public static async Task<Boolean> Delete(this Session? session, Installation? installation)
{
var user = session?.User;
return user is not null
&& installation is not null
&& user.UserType != 0
&& user.HasAccessTo(installation)
&& Db.Delete(installation)
&& await installation.DeleteBucket();
}
public static Boolean Create(this Session? session, User newUser)

View File

@ -12,18 +12,19 @@ namespace InnovEnergy.App.Backend.DataTypes.Methods;
public static class UserMethods
{
public static IEnumerable<Installation> AccessibleInstallations(this User user)
public static IEnumerable<Installation> AccessibleInstallations(this User user,int product)
{
var direct = user.DirectlyAccessibleInstallations().ToList();
var direct = user.DirectlyAccessibleInstallations().ToList().Where(f=>f.Product==product);
var fromFolders = user
.AccessibleFolders()
.SelectMany(u => u.ChildInstallations()).ToList();
.SelectMany(u => u.ChildInstallations()).ToList().Where(f=>f.Product==product);
return direct
.Concat(fromFolders)
.Distinct();
}
public static IEnumerable<Folder> AccessibleFolders(this User user)
{
return user
@ -32,12 +33,12 @@ public static class UserMethods
.Distinct();
}
public static IEnumerable<TreeNode> AccessibleFoldersAndInstallations(this User user)
public static IEnumerable<TreeNode> AccessibleFoldersAndInstallations(this User user,int product)
{
var folders = user.AccessibleFolders() as IEnumerable<TreeNode>;
user.AccessibleInstallations().ForEach(i => i.FillOrderNumbers());
var installations = user.AccessibleInstallations();
user.AccessibleInstallations(product).ForEach(i => i.FillOrderNumbers());
var installations = user.AccessibleInstallations(product);
return folders.Concat(installations);
}
@ -158,6 +159,7 @@ public static class UserMethods
.Any(user.HasDirectAccessTo);
}
public static Boolean HasAccessTo(this User user, User? other)
{
if (other is null)
@ -169,19 +171,10 @@ public static class UserMethods
.Contains(user);
}
public static Boolean HasAccessTo(this User user, TreeNode? other)
{
return other?.Type switch
{
"installation" => user.HasAccessTo((Installation)other),
"user" => user.HasAccessTo((User)other),
"folder" => user.HasAccessTo((Folder)other),
_ => false
};
}
public static Boolean HasAccessToParentOf(this User user, TreeNode? other)
{
return other?.Type switch
{
"Installation" => user.HasAccessTo(Db.GetFolderById(other.ParentId)),

View File

@ -1,5 +1,3 @@
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using InnovEnergy.App.Backend.DataTypes;
using InnovEnergy.App.Backend.DataTypes.Methods;
using InnovEnergy.App.Backend.Relations;
@ -143,11 +141,15 @@ public static partial class Db
.Distinct()
.ToList();
var validWriteKeys = Installations
.Select(i => i.S3WriteKey)
.Distinct()
.ToList();
const String provider = "exo.io";
var S3keys = await ExoCmd.GetAccessKeys();
@ -243,6 +245,7 @@ public static partial class Db
{
await installation.RenewS3Credentials();
}
}
}

View File

@ -72,10 +72,16 @@ public static partial class Db
Boolean DeleteInstallationAndItsDependencies()
{
if (installation.Product == 0)
{
InstallationAccess.Delete(i => i.InstallationId == installation.Id);
OrderNumber2Installation.Delete(i => i.InstallationId == installation.Id);
}
return Installations.Delete(i => i.Id == installation.Id) > 0;
}
}

View File

@ -9,4 +9,5 @@ Salimax0005 ie-entwicklung@10.2.4.36 Schreinerei Schönthal (Thu
Salimax0006 ie-entwicklung@10.2.4.35 Steakhouse Mettmenstetten
Salimax0007 ie-entwicklung@10.2.4.154 LerchenhofHerr Twannberg
Salimax0008 ie-entwicklung@10.2.4.113 Wittmann Kottingbrunn
Salimax0010 ie-entwicklung@10.2.4.211 Mahotech 1
SalidomoServer ig@134.209.238.170

View File

@ -20,6 +20,7 @@ import { axiosConfigWithoutToken } from './Resources/axiosConfig';
import InstallationsContextProvider from './contexts/InstallationsContextProvider';
import AccessContextProvider from './contexts/AccessContextProvider';
import WebSocketContextProvider from './contexts/WebSocketContextProvider';
import SalidomoInstallationTabs from './content/dashboards/SalidomoInstallations';
function App() {
const context = useContext(UserContext);
@ -146,6 +147,18 @@ function App() {
</AccessContextProvider>
}
/>
<Route
path={routes.salidomo_installations + '*'}
element={
<AccessContextProvider>
<InstallationsContextProvider>
<SalidomoInstallationTabs />
</InstallationsContextProvider>
</AccessContextProvider>
}
/>
<Route path={routes.users + '*'} element={<Users />} />
<Route
path={'*'}

View File

@ -1,6 +1,7 @@
{
"users": "/users/",
"installations": "/installations/",
"salidomo_installations": "/salidomo_installations/",
"installation": "installation/",
"login": "/login/",
"forgotPassword": "/forgotPassword/",

View File

@ -116,7 +116,7 @@ function Configuration(props: ConfigurationProps) {
setErrorDateModalOpen(true);
return;
} else if (
formValues.CalibrationChargeState != 2 &&
formValues.CalibrationChargeState === 1 &&
dayjs(formValues.calibrationChargeDate).isBefore(dayjs())
) {
//console.log('asked for', dayjs(formValues.calibrationChargeDate));

View File

@ -0,0 +1,389 @@
import {
Alert,
Box,
CardContent,
CircularProgress,
Container,
Grid,
IconButton,
Modal,
TextField,
Typography,
useTheme
} from '@mui/material';
import { FormattedMessage } from 'react-intl';
import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material';
import React, { useContext, useState } from 'react';
import { I_S3Credentials } from '../../../interfaces/S3Types';
import { I_Installation } from '../../../interfaces/InstallationTypes';
import { InstallationsContext } from '../../../contexts/InstallationsContextProvider';
import { UserContext } from '../../../contexts/userContext';
import routes from '../../../Resources/routes.json';
import { useNavigate } from 'react-router-dom';
interface InformationSalidomoProps {
values: I_Installation;
s3Credentials: I_S3Credentials;
type?: string;
}
function InformationSalidomo(props: InformationSalidomoProps) {
if (props.values === null) {
return null;
}
const context = useContext(UserContext);
const { currentUser } = context;
const theme = useTheme();
const [formValues, setFormValues] = useState(props.values);
const requiredFields = ['name', 'region', 'location', 'country'];
const [openModalDeleteInstallation, setOpenModalDeleteInstallation] =
useState(false);
const navigate = useNavigate();
const installationContext = useContext(InstallationsContext);
const {
updateInstallation,
deleteInstallation,
loading,
setLoading,
error,
setError,
updated,
setUpdated
} = installationContext;
const handleChange = (e) => {
const { name, value } = e.target;
setFormValues({
...formValues,
[name]: value
});
};
const handleSubmit = () => {
setLoading(true);
setError(false);
updateInstallation(formValues, props.type);
};
const handleDelete = () => {
setLoading(true);
setError(false);
setOpenModalDeleteInstallation(true);
};
const deleteInstallationModalHandle = () => {
setOpenModalDeleteInstallation(false);
deleteInstallation(formValues, props.type);
setLoading(false);
navigate(routes.salidomo_installations + routes.list, {
replace: true
});
};
const deleteInstallationModalHandleCancel = () => {
setOpenModalDeleteInstallation(false);
setLoading(false);
};
const areRequiredFieldsFilled = () => {
for (const field of requiredFields) {
if (!formValues[field]) {
return false;
}
}
return true;
};
return (
<>
{openModalDeleteInstallation && (
<Modal
open={openModalDeleteInstallation}
onClose={deleteInstallationModalHandleCancel}
aria-labelledby="error-modal"
aria-describedby="error-modal-description"
>
<Box
sx={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 350,
bgcolor: 'background.paper',
borderRadius: 4,
boxShadow: 24,
p: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<Typography
variant="body1"
gutterBottom
sx={{ fontWeight: 'bold' }}
>
Do you want to delete this installation?
</Typography>
<div
style={{
display: 'flex',
alignItems: 'center',
marginTop: 10
}}
>
<Button
sx={{
marginTop: 2,
textTransform: 'none',
bgcolor: '#ffc04d',
color: '#111111',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={deleteInstallationModalHandle}
>
Delete
</Button>
<Button
sx={{
marginTop: 2,
marginLeft: 2,
textTransform: 'none',
bgcolor: '#ffc04d',
color: '#111111',
'&:hover': { bgcolor: '#f7b34d' }
}}
onClick={deleteInstallationModalHandleCancel}
>
Cancel
</Button>
</div>
</Box>
</Modal>
)}
<Container maxWidth="xl">
<Grid
container
direction="row"
justifyContent="center"
alignItems="stretch"
spacing={3}
>
<Grid item xs={12} md={12}>
<CardContent>
<Box
component="form"
sx={{
'& .MuiTextField-root': { m: 1, width: '50ch' }
}}
noValidate
autoComplete="off"
>
<div>
<TextField
label={
<FormattedMessage
id="installation_name"
defaultMessage="Installation Name"
/>
}
name="installationName"
value={formValues.name}
onChange={handleChange}
variant="outlined"
fullWidth
/>
</div>
<div>
<TextField
label={
<FormattedMessage id="region" defaultMessage="Region" />
}
name="region"
value={formValues.region}
onChange={handleChange}
variant="outlined"
fullWidth
required
error={formValues.region === ''}
/>
</div>
<div>
<TextField
label={
<FormattedMessage
id="location"
defaultMessage="Location"
/>
}
name="location"
value={formValues.location}
onChange={handleChange}
variant="outlined"
fullWidth
required
error={formValues.location === ''}
/>
</div>
<div>
<TextField
label={
<FormattedMessage id="country" defaultMessage="Country" />
}
name="country"
value={formValues.country}
onChange={handleChange}
variant="outlined"
fullWidth
required
error={formValues.country === ''}
/>
</div>
<div>
<TextField
label={
<FormattedMessage id="vpnip" defaultMessage="VPN IP" />
}
name="vpnIp"
value={formValues.vpnIp}
onChange={handleChange}
variant="outlined"
fullWidth
/>
</div>
<div>
<TextField
label={
<FormattedMessage
id="information"
defaultMessage="Information"
/>
}
name="information"
value={formValues.information}
onChange={handleChange}
variant="outlined"
fullWidth
/>
</div>
<div>
<TextField
label="S3 Bucket Name"
name="s3writesecretkey"
value={
formValues.id + '-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e'
}
variant="outlined"
fullWidth
/>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
marginTop: 10
}}
>
<Button
variant="contained"
onClick={handleSubmit}
sx={{
marginLeft: '10px'
}}
disabled={!areRequiredFieldsFilled()}
>
<FormattedMessage
id="applyChanges"
defaultMessage="Apply Changes"
/>
</Button>
<Button
variant="contained"
onClick={handleDelete}
sx={{
marginLeft: '10px'
}}
>
<FormattedMessage
id="deleteInstallation"
defaultMessage="Delete Installation"
/>
</Button>
{loading && (
<CircularProgress
sx={{
color: theme.palette.primary.main,
marginLeft: '20px'
}}
/>
)}
{error && (
<Alert
severity="error"
sx={{
ml: 1,
display: 'flex',
alignItems: 'center'
}}
>
<FormattedMessage
id="errorOccured"
defaultMessage="An error has occurred"
/>
<IconButton
color="inherit"
size="small"
onClick={() => setError(false)}
sx={{ marginLeft: '4px' }}
>
<CloseIcon fontSize="small" />
</IconButton>
</Alert>
)}
{updated && (
<Alert
severity="success"
sx={{
ml: 1,
display: 'flex',
alignItems: 'center'
}}
>
<FormattedMessage
id="successfullyUpdated"
defaultMessage="Successfully updated"
/>
<IconButton
color="inherit"
size="small"
onClick={() => setUpdated(false)} // Set error state to false on click
sx={{ marginLeft: '4px' }}
>
<CloseIcon fontSize="small" />
</IconButton>
</Alert>
)}
</div>
</Box>
</CardContent>
</Grid>
</Grid>
</Container>
</>
);
}
export default InformationSalidomo;

View File

@ -52,8 +52,11 @@ function Installation(props: singleInstallationProps) {
};
const s3Bucket =
props.current_installation.id.toString() +
'-3e5b3069-214a-43ee-8d85-57d72000c19d';
props.current_installation.product === 0
? props.current_installation.id.toString() +
'-3e5b3069-214a-43ee-8d85-57d72000c19d'
: props.current_installation.id.toString() +
'-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e';
const s3Credentials = { s3Bucket, ...S3data };

View File

@ -30,7 +30,7 @@ function InstallationTabs() {
];
const [currentTab, setCurrentTab] = useState<string>(undefined);
const { installations, fetchAllInstallations } =
const { salimaxInstallations, fetchAllInstallations } =
useContext(InstallationsContext);
const webSocketsContext = useContext(WebSocketContext);
@ -50,16 +50,16 @@ function InstallationTabs() {
}, [location]);
useEffect(() => {
if (!socket && installations.length > 0) {
openSocket(installations);
if (!socket && salimaxInstallations.length > 0) {
openSocket(salimaxInstallations);
}
}, [installations]);
}, [salimaxInstallations]);
useEffect(() => {
if (installations.length === 0) {
if (salimaxInstallations.length === 0) {
fetchAllInstallations();
}
}, [installations]);
}, [salimaxInstallations]);
const handleTabsChange = (_event: ChangeEvent<{}>, value: string): void => {
setCurrentTab(value);
@ -270,7 +270,7 @@ function InstallationTabs() {
}
];
return installations.length > 1 ? (
return salimaxInstallations.length > 1 ? (
<>
<Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe">
<TabsContainerWrapper>
@ -312,7 +312,9 @@ function InstallationTabs() {
element={
<Grid item xs={12}>
<Box p={4}>
<InstallationSearch installations={installations} />
<InstallationSearch
installations={salimaxInstallations}
/>
</Box>
</Grid>
}
@ -330,7 +332,7 @@ function InstallationTabs() {
</Container>
<Footer />
</>
) : installations.length === 1 ? (
) : salimaxInstallations.length === 1 ? (
<>
<Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe">
<TabsContainerWrapper>
@ -368,7 +370,7 @@ function InstallationTabs() {
<Grid item xs={12}>
<Box p={4}>
<Installation
current_installation={installations[0]}
current_installation={salimaxInstallations[0]}
type="installation"
></Installation>
</Box>

View File

@ -51,6 +51,7 @@ function installationForm(props: installationFormProps) {
const handleSubmit = async (e) => {
setLoading(true);
formValues.parentId = props.parentid;
formValues.product = 0;
const responseData = await createInstallation(formValues);
props.submit();
};

View File

@ -0,0 +1,208 @@
import React, { useContext, useState } from 'react';
import {
Card,
Grid,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
useTheme
} from '@mui/material';
import { I_Installation } from 'src/interfaces/InstallationTypes';
import { WebSocketContext } from 'src/contexts/WebSocketContextProvider';
import { FormattedMessage } from 'react-intl';
import { useLocation, useNavigate } from 'react-router-dom';
import routes from '../../../Resources/routes.json';
interface FlatInstallationViewProps {
installations: I_Installation[];
}
const FlatInstallationView = (props: FlatInstallationViewProps) => {
const [isRowHovered, setHoveredRow] = useState(-1);
const webSocketContext = useContext(WebSocketContext);
const { getStatus } = webSocketContext;
const navigate = useNavigate();
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
const currentLocation = useLocation();
const handleSelectOneInstallation = (installationID: number): void => {
if (selectedInstallation != installationID) {
setSelectedInstallation(installationID);
setSelectedInstallation(-1);
navigate(
routes.salidomo_installations +
routes.list +
routes.installation +
`${installationID}` +
'/' +
routes.batteryview,
{
replace: true
}
);
} else {
setSelectedInstallation(-1);
}
};
const theme = useTheme();
const handleRowMouseEnter = (id: number) => {
setHoveredRow(id);
};
const handleRowMouseLeave = () => {
setHoveredRow(-1);
};
return (
<Grid container spacing={1} sx={{ marginTop: 0.1 }}>
<Grid
item
sx={{
display:
currentLocation.pathname ===
routes.salidomo_installations + 'list' ||
currentLocation.pathname ===
routes.salidomo_installations + routes.list
? 'block'
: 'none'
}}
>
<Card>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>
<FormattedMessage id="name" defaultMessage="Name" />
</TableCell>
<TableCell>
<FormattedMessage id="location" defaultMessage="Location" />
</TableCell>
<TableCell>
<FormattedMessage id="region" defaultMessage="Region" />
</TableCell>
<TableCell>
<FormattedMessage id="country" defaultMessage="Country" />
</TableCell>
<TableCell>
<FormattedMessage id="VRM Link" defaultMessage="VRM Link" />
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{props.installations.map((installation) => {
const isInstallationSelected =
installation.id === selectedInstallation;
const status = getStatus(installation.id);
const rowStyles =
isRowHovered === installation.id
? {
cursor: 'pointer',
backgroundColor: theme.colors.primary.lighter
}
: {};
return (
<TableRow
hover
key={installation.id}
selected={isInstallationSelected}
style={rowStyles}
onClick={() =>
handleSelectOneInstallation(installation.id)
}
onMouseEnter={() => handleRowMouseEnter(installation.id)}
onMouseLeave={() => handleRowMouseLeave()}
>
<TableCell>
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
{installation.name}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
{installation.location}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
{installation.region}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
{installation.country}
</Typography>
</TableCell>
<TableCell>
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
<a
href={installation.vrmLink}
target="_blank"
rel="noopener noreferrer"
>
VRM link
</a>
</Typography>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Card>
</Grid>
</Grid>
);
};
export default FlatInstallationView;

View File

@ -0,0 +1,205 @@
import React, { useContext, useEffect, useState } from 'react';
import { Card, Grid, Typography } from '@mui/material';
import { I_Installation } from 'src/interfaces/InstallationTypes';
import { UserContext } from 'src/contexts/userContext';
import { TimeSpan, UnixTime } from 'src/dataCache/time';
import { FetchResult } from 'src/dataCache/dataCache';
import {
extractValues,
TopologyValues
} from 'src/content/dashboards/Log/graph.util';
import { WebSocketContext } from 'src/contexts/WebSocketContextProvider';
import { FormattedMessage } from 'react-intl';
import Overview from '../Overview/overview';
import { fetchData } from 'src/content/dashboards/Installations/fetchData';
import { Navigate, Route, Routes, useLocation } from 'react-router-dom';
import routes from '../../../Resources/routes.json';
import BatteryView from '../BatteryView/BatteryView';
import InformationSalidomo from '../Information/InformationSalidomo';
interface singleInstallationProps {
current_installation?: I_Installation;
type?: string;
}
function Installation(props: singleInstallationProps) {
const context = useContext(UserContext);
const { currentUser } = context;
const location = useLocation().pathname;
const [errorLoadingS3Data, setErrorLoadingS3Data] = useState(false);
const webSocketsContext = useContext(WebSocketContext);
const { getStatus } = webSocketsContext;
const [currentTab, setCurrentTab] = useState<string>(undefined);
const [values, setValues] = useState<TopologyValues | null>(null);
const status = getStatus(props.current_installation.id);
if (props.current_installation == undefined) {
return null;
}
const S3data = {
s3Region: props.current_installation.s3Region,
s3Provider: props.current_installation.s3Provider,
s3Key: props.current_installation.s3Key,
s3Secret: props.current_installation.s3Secret
};
const s3Bucket =
props.current_installation.id.toString() +
'-' +
'c0436b6a-d276-4cd8-9c44-1eae86cf5d0e';
const s3Credentials = { s3Bucket, ...S3data };
const fetchDataPeriodically = async () => {
const now = UnixTime.now().earlier(TimeSpan.fromSeconds(20));
try {
const res = await fetchData(now, s3Credentials);
if (res != FetchResult.notAvailable && res != FetchResult.tryLater) {
setValues(
extractValues({
time: now,
value: res
})
);
return true;
}
} catch (err) {
return false;
}
};
const fetchDataOnlyOneTime = async () => {
let success = false;
const max_retransmissions = 3;
for (let i = 0; i < max_retransmissions; i++) {
success = await fetchDataPeriodically();
await new Promise((resolve) => setTimeout(resolve, 1000));
if (success) {
break;
}
}
};
useEffect(() => {
let path = location.split('/');
setCurrentTab(path[path.length - 1]);
}, [location]);
useEffect(() => {
if (
currentTab == 'live' ||
currentTab == 'configuration' ||
location.includes('batteryview')
) {
let interval;
if (
currentTab == 'live' ||
(location.includes('batteryview') && !location.includes('mainstats'))
) {
fetchDataPeriodically();
interval = setInterval(fetchDataPeriodically, 2000);
}
if (currentTab == 'configuration' || location.includes('mainstats')) {
fetchDataOnlyOneTime();
}
// Cleanup function to cancel interval and update isMounted when unmounted
return () => {
if (
currentTab == 'live' ||
(location.includes('batteryview') && !location.includes('mainstats'))
) {
clearInterval(interval);
}
};
}
}, [currentTab, location]);
return (
<>
<Grid item xs={12} md={12}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Typography
fontWeight="bold"
color="text.primary"
noWrap
sx={{
marginTop: '-20px',
marginBottom: '10px',
fontSize: '14px'
}}
>
<FormattedMessage
id="installation_name_simple"
defaultMessage="Installation Name:"
/>
</Typography>
<Typography
fontWeight="bold"
color="orange"
noWrap
sx={{
marginTop: '-20px',
marginBottom: '10px',
marginLeft: '5px',
fontSize: '14px'
}}
>
{props.current_installation.name}
</Typography>
</div>
<Card variant="outlined">
<Grid
container
direction="row"
justifyContent="center"
alignItems="stretch"
spacing={0}
>
<Routes>
<Route
path={routes.information}
element={
<InformationSalidomo
values={props.current_installation}
s3Credentials={s3Credentials}
type={props.type}
></InformationSalidomo>
}
/>
<Route
path={routes.batteryview + '*'}
element={
<BatteryView
values={values}
s3Credentials={s3Credentials}
installationId={props.current_installation.id}
></BatteryView>
}
></Route>
<Route
path={routes.overview}
element={<Overview s3Credentials={s3Credentials}></Overview>}
/>
<Route
path={'*'}
element={<Navigate to={routes.information}></Navigate>}
/>
</Routes>
</Grid>
</Card>
</Grid>
</>
);
}
export default Installation;

View File

@ -0,0 +1,127 @@
import React, { useEffect, useState } from 'react';
import { FormControl, Grid, InputAdornment, TextField } from '@mui/material';
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
import FlatInstallationView from './FlatInstallationView';
import { I_Installation } from '../../../interfaces/InstallationTypes';
import { Route, Routes, useLocation } from 'react-router-dom';
import routes from '../../../Resources/routes.json';
import Installation from './Installation';
import { FormattedMessage } from 'react-intl';
import Button from '@mui/material/Button';
import SalidomonstallationForm from './SalidomoInstallationForm';
interface installationSearchProps {
installations: I_Installation[];
}
function InstallationSearch(props: installationSearchProps) {
const [searchTerm, setSearchTerm] = useState('');
const currentLocation = useLocation();
const [filteredData, setFilteredData] = useState(props.installations);
const [openModalInstallation, setOpenModalInstallation] = useState(false);
const handleNewInstallationInsertion = () => {
setOpenModalInstallation(true);
};
const handleInstallationFormSubmit = () => {
setOpenModalInstallation(false);
};
const handleFormCancel = () => {
setOpenModalInstallation(false);
};
useEffect(() => {
const filtered = props.installations.filter(
(item) =>
item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.location.toLowerCase().includes(searchTerm.toLowerCase()) ||
item.region.toLowerCase().includes(searchTerm.toLowerCase())
);
setFilteredData(filtered);
}, [searchTerm, props.installations]);
return (
<>
{openModalInstallation && (
<SalidomonstallationForm
cancel={handleFormCancel}
submit={handleInstallationFormSubmit}
/>
)}
<Grid container>
<Grid
item
xs={12}
md={6}
sx={{
display:
currentLocation.pathname ===
routes.salidomo_installations + 'list' ||
currentLocation.pathname ===
routes.salidomo_installations + routes.list
? 'block'
: 'none'
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start'
}}
>
<Button
variant="contained"
onClick={handleNewInstallationInsertion}
sx={{ marginBottom: '8px' }}
>
<FormattedMessage
id="addNewInstallation"
defaultMessage="Add new installation"
/>
</Button>
<FormControl variant="outlined">
<TextField
placeholder="Search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchTwoToneIcon />
</InputAdornment>
)
}}
/>
</FormControl>
</div>
</Grid>
</Grid>
<FlatInstallationView installations={filteredData} />
<Routes>
{filteredData.map((installation) => {
return (
<Route
key={installation.id}
path={routes.installation + installation.id + '*'}
element={
<Installation
key={installation.id}
current_installation={installation}
type="installation"
></Installation>
}
/>
);
})}
</Routes>
</>
);
}
export default InstallationSearch;

View File

@ -0,0 +1,258 @@
import React, { useContext, useState } from 'react';
import {
Alert,
Box,
CircularProgress,
IconButton,
Modal,
TextField,
useTheme
} from '@mui/material';
import Button from '@mui/material/Button';
import { Close as CloseIcon } from '@mui/icons-material';
import { I_Installation } from 'src/interfaces/InstallationTypes';
import { InstallationsContext } from 'src/contexts/InstallationsContextProvider';
import { FormattedMessage } from 'react-intl';
interface SalidomoInstallationFormProps {
cancel: () => void;
submit: () => void;
}
function SalidomonstallationForm(props: SalidomoInstallationFormProps) {
const theme = useTheme();
const [open, setOpen] = useState(true);
const [formValues, setFormValues] = useState<Partial<I_Installation>>({
name: '',
region: '',
location: '',
country: '',
vpnIp: '',
vrmLink: ''
});
const requiredFields = ['name', 'location', 'country', 'vpnIp', 'vrmLink'];
const installationContext = useContext(InstallationsContext);
const { createInstallation, loading, setLoading, error, setError } =
installationContext;
const handleChange = (e) => {
const { name, value } = e.target;
setFormValues({
...formValues,
[name]: value
});
};
const handleSubmit = async (e) => {
setLoading(true);
formValues.parentId = 1;
formValues.product = 1;
const responseData = await createInstallation(formValues);
props.submit();
};
const handleCancelSubmit = (e) => {
props.cancel();
};
const areRequiredFieldsFilled = () => {
for (const field of requiredFields) {
if (!formValues[field]) {
return false;
}
}
return true;
};
const isMobile = window.innerWidth <= 1490;
return (
<>
<Modal
open={open}
onClose={() => {}}
aria-labelledby="error-modal"
aria-describedby="error-modal-description"
>
<Box
sx={{
position: 'absolute',
top: isMobile ? '50%' : '40%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: 500,
bgcolor: 'background.paper',
borderRadius: 4,
boxShadow: 24,
p: 4
}}
>
<Box
component="form"
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center', // Center items horizontally
'& .MuiTextField-root': {
m: 1,
width: 390
}
}}
noValidate
autoComplete="off"
>
<div>
<TextField
label={
<FormattedMessage
id="installationName"
defaultMessage="Installation Name"
/>
}
name="name"
value={formValues.name}
onChange={handleChange}
required
error={formValues.name === ''}
/>
</div>
<div>
<TextField
label={<FormattedMessage id="region" defaultMessage="Region" />}
name="region"
value={formValues.region}
onChange={handleChange}
required
/>
</div>
<div>
<TextField
label={
<FormattedMessage id="location" defaultMessage="Location" />
}
name="location"
value={formValues.location}
onChange={handleChange}
required
error={formValues.location === ''}
/>
</div>
<div>
<TextField
label={
<FormattedMessage id="country" defaultMessage="Country" />
}
name="country"
value={formValues.country}
onChange={handleChange}
required
error={formValues.country === ''}
/>
</div>
<div>
<TextField
label={<FormattedMessage id="VpnIp" defaultMessage="VpnIp" />}
name="vpnIp"
value={formValues.vpnIp}
onChange={handleChange}
required
error={formValues.vpnIp === ''}
/>
</div>
<div>
<TextField
label={
<FormattedMessage id="VRM Link" defaultMessage="VRM Link" />
}
name="vrmLink"
value={formValues.vrmLink}
onChange={handleChange}
required
error={formValues.vrmLink === ''}
/>
</div>
<div>
<TextField
label={
<FormattedMessage
id="Information"
defaultMessage="Information"
/>
}
name="information"
value={formValues.information}
onChange={handleChange}
/>
</div>
</Box>
<div
style={{
display: 'flex',
alignItems: 'center',
marginTop: 10
}}
>
<Button
variant="contained"
onClick={handleSubmit}
sx={{
marginLeft: '20px'
}}
disabled={!areRequiredFieldsFilled()}
>
<FormattedMessage id="submit" defaultMessage="Submit" />
</Button>
<Button
variant="contained"
onClick={handleCancelSubmit}
sx={{
marginLeft: '10px'
}}
>
<FormattedMessage id="cancel" defaultMessage="Cancel" />
</Button>
{loading && (
<CircularProgress
sx={{
color: theme.palette.primary.main,
marginLeft: '20px'
}}
/>
)}
{error && (
<Alert
severity="error"
sx={{
ml: 1,
display: 'flex',
alignItems: 'center'
}}
>
<FormattedMessage
id="errorOccured"
defaultMessage="An error has occured"
/>
<IconButton
color="inherit"
size="small"
onClick={() => setError(false)}
sx={{ marginLeft: '4px' }}
>
<CloseIcon fontSize="small" />
</IconButton>
</Alert>
)}
</div>
</Box>
</Modal>
</>
);
}
export default SalidomonstallationForm;

View File

@ -0,0 +1,163 @@
import React, { ChangeEvent, useContext, useEffect, useState } from 'react';
import { Box, Card, Container, Grid, Tab, Tabs } from '@mui/material';
import { TabsContainerWrapper } from 'src/layouts/TabsContainerWrapper';
import { Link, Navigate, Route, Routes, useLocation } from 'react-router-dom';
import routes from 'src/Resources/routes.json';
import InstallationSearch from './InstallationSearch';
import { FormattedMessage } from 'react-intl';
import { UserContext } from '../../../contexts/userContext';
import { InstallationsContext } from '../../../contexts/InstallationsContextProvider';
import { WebSocketContext } from '../../../contexts/WebSocketContextProvider';
import ListIcon from '@mui/icons-material/List';
function SalidomoInstallationTabs() {
const location = useLocation();
const context = useContext(UserContext);
const { currentUser } = context;
const tabList = ['batteryview', 'information'];
const [currentTab, setCurrentTab] = useState<string>(undefined);
const [fetchedInstallations, setFetchedInstallations] =
useState<boolean>(false);
const { salidomoInstallations, fetchAllSalidomoInstallations } =
useContext(InstallationsContext);
const webSocketsContext = useContext(WebSocketContext);
const { socket, openSocket } = webSocketsContext;
useEffect(() => {
let path = location.pathname.split('/');
if (path[path.length - 2] === 'list') {
setCurrentTab('list');
} else {
//Even if we are located at path: /batteryview/mainstats, we want the BatteryView tab to be bold
setCurrentTab(path.find((pathElement) => tabList.includes(pathElement)));
}
}, [location]);
useEffect(() => {
if (salidomoInstallations.length === 0 && fetchedInstallations === false) {
fetchAllSalidomoInstallations();
setFetchedInstallations(true);
}
}, [salidomoInstallations]);
const handleTabsChange = (_event: ChangeEvent<{}>, value: string): void => {
setCurrentTab(value);
};
const navigateToTabPath = (pathname: string, tab_value: string): string => {
let pathlist = pathname.split('/');
let ret_path = '';
for (let i = 1; i < pathlist.length; i++) {
if (Number.isNaN(Number(pathlist[i]))) {
ret_path += '/';
ret_path += pathlist[i];
} else {
ret_path += '/';
ret_path += pathlist[i];
ret_path += '/';
break;
}
}
ret_path += tab_value;
return ret_path;
};
const tabs =
currentTab != 'list' && !location.pathname.includes('folder')
? [
{
value: 'list',
icon: <ListIcon id="mode-toggle-button-list-icon" />
},
{
value: 'batteryview',
label: (
<FormattedMessage
id="batteryview"
defaultMessage="Battery View"
/>
)
},
{
value: 'information',
label: (
<FormattedMessage id="information" defaultMessage="Information" />
)
}
]
: [
{
value: 'list',
icon: <ListIcon id="mode-toggle-button-list-icon" />
}
];
return (
<Container maxWidth="xl" sx={{ marginTop: '20px' }} className="mainframe">
<TabsContainerWrapper>
<Tabs
onChange={handleTabsChange}
value={currentTab}
variant="scrollable"
scrollButtons="auto"
textColor="primary"
indicatorColor="primary"
>
{tabs.map((tab) => (
<Tab
key={tab.value}
value={tab.value}
icon={tab.icon}
component={Link}
label={tab.label}
to={
tab.value === 'list'
? routes[tab.value]
: navigateToTabPath(location.pathname, routes[tab.value])
}
/>
))}
</Tabs>
</TabsContainerWrapper>
<Card variant="outlined">
<Grid
container
direction="row"
justifyContent="center"
alignItems="stretch"
spacing={0}
>
<Routes>
<Route
path={routes.list + '*'}
element={
<Grid item xs={12}>
<Box p={4}>
<InstallationSearch installations={salidomoInstallations} />
</Box>
</Grid>
}
/>
<Route
path={'*'}
element={
<Navigate
to={routes.salidomo_installations + routes.list}
></Navigate>
}
></Route>
</Routes>
</Grid>
</Card>
</Container>
);
}
export default SalidomoInstallationTabs;

View File

@ -13,9 +13,11 @@ import routes from '../Resources/routes.json';
import { useNavigate } from 'react-router-dom';
interface I_InstallationContextProviderProps {
installations: I_Installation[];
salimaxInstallations: I_Installation[];
salidomoInstallations: I_Installation[];
foldersAndInstallations: I_Installation[];
fetchAllInstallations: () => Promise<void>;
fetchAllSalidomoInstallations: () => Promise<void>;
fetchAllFoldersAndInstallations: () => Promise<void>;
createInstallation: (value: Partial<I_Installation>) => Promise<void>;
updateInstallation: (value: I_Installation, view: string) => Promise<void>;
@ -33,9 +35,11 @@ interface I_InstallationContextProviderProps {
export const InstallationsContext =
createContext<I_InstallationContextProviderProps>({
installations: [],
salimaxInstallations: [],
salidomoInstallations: [],
foldersAndInstallations: [],
fetchAllInstallations: () => Promise.resolve(),
fetchAllSalidomoInstallations: () => Promise.resolve(),
fetchAllFoldersAndInstallations: () => Promise.resolve(),
createInstallation: () => Promise.resolve(),
updateInstallation: () => Promise.resolve(),
@ -56,7 +60,12 @@ const InstallationsContextProvider = ({
}: {
children: ReactNode;
}) => {
const [installations, setInstallations] = useState<I_Installation[]>([]);
const [salimaxInstallations, setSalimaxInstallations] = useState<
I_Installation[]
>([]);
const [salidomoInstallations, setSalidomoInstallations] = useState<
I_Installation[]
>([]);
const [foldersAndInstallations, setFoldersAndInstallations] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
@ -70,7 +79,22 @@ const InstallationsContextProvider = ({
axiosConfig
.get('/GetAllInstallations', {})
.then((res: AxiosResponse<I_Installation[]>) => {
setInstallations(res.data);
setSalimaxInstallations(res.data);
})
.catch((err: AxiosError) => {
if (err.response && err.response.status == 401) {
removeToken();
navigate(routes.login);
}
});
}, []);
const fetchAllSalidomoInstallations = useCallback(async () => {
let isMounted = true;
axiosConfig
.get('/GetAllSalidomoInstallations', {})
.then((res: AxiosResponse<I_Installation[]>) => {
setSalidomoInstallations(res.data);
})
.catch((err: AxiosError) => {
if (err.response && err.response.status == 401) {
@ -100,7 +124,11 @@ const InstallationsContextProvider = ({
.post('/CreateInstallation', formValues)
.then((res) => {
setLoading(false);
if (formValues.product == 0) {
fetchAllFoldersAndInstallations();
} else {
fetchAllSalidomoInstallations();
}
})
.catch((error) => {
@ -123,11 +151,15 @@ const InstallationsContextProvider = ({
if (response) {
setLoading(false);
setUpdated(true);
if (formValues.product == 0) {
if (view == 'installation') {
fetchAllInstallations();
} else {
fetchAllFoldersAndInstallations();
}
} else {
fetchAllSalidomoInstallations();
}
setTimeout(() => {
setUpdated(false);
@ -154,11 +186,15 @@ const InstallationsContextProvider = ({
if (response) {
setLoading(false);
setUpdated(true);
if (formValues.product == 0) {
if (view == 'installation') {
fetchAllInstallations();
} else {
fetchAllFoldersAndInstallations();
}
} else {
fetchAllSalidomoInstallations();
}
setTimeout(() => {
setUpdated(false);
@ -246,9 +282,11 @@ const InstallationsContextProvider = ({
return (
<InstallationsContext.Provider
value={{
installations,
salimaxInstallations,
salidomoInstallations,
foldersAndInstallations,
fetchAllInstallations,
fetchAllSalidomoInstallations,
fetchAllFoldersAndInstallations,
createInstallation,
updateInstallation,

View File

@ -2,24 +2,21 @@ import { I_S3Credentials } from 'src/interfaces/S3Types';
export interface I_Installation extends I_S3Credentials {
type: string;
title?: string;
status?: number;
detail?: string;
instance?: string;
vrmLink?: string;
vrmIdentifier?: string;
location: string;
region: string;
country: string;
installationName: string;
vpnIp: string;
orderNumbers: string[] | string;
lat: number;
long: number;
id: number;
name: string;
information: string;
parentId: number;
s3WriteKey: string;
s3WriteSecret: string;
product: number;
}
export interface I_Folder {

View File

@ -170,7 +170,9 @@ function SidebarMenu() {
<List
component="div"
subheader={
<ListSubheader component="div" disableSticky></ListSubheader>
<ListSubheader component="div" disableSticky>
Products
</ListSubheader>
}
>
<SubMenuWrapper>
@ -183,13 +185,33 @@ function SidebarMenu() {
to="/installations"
startIcon={<BrightnessLowTwoToneIcon />}
>
<FormattedMessage
id="installations"
defaultMessage="Installations"
/>
<Box sx={{ marginTop: '3px' }}>
<FormattedMessage id="salimax" defaultMessage="Salimax" />
</Box>
</Button>
</ListItem>
</List>
{currentUser.userType == UserType.admin && (
<List component="div">
<ListItem component="div">
<Button
disableRipple
component={RouterLink}
onClick={closeSidebar}
to="/salidomo_installations"
startIcon={<BrightnessLowTwoToneIcon />}
>
<Box sx={{ marginTop: '3px' }}>
<FormattedMessage
id="salidomo"
defaultMessage="Salidomo"
/>
</Box>
</Button>
</ListItem>
</List>
)}
</SubMenuWrapper>
</List>