Update frontend to support fast search in salidomo.

Also, the user can sort based on status and action flag
Update aggregator for Salidomo so that it uses json
This commit is contained in:
Noe 2025-04-10 14:13:23 +02:00
parent 41ca486edb
commit 96359fab08
3 changed files with 448 additions and 416 deletions

View File

@ -137,9 +137,6 @@ public static class Aggregator
var jsonObject = JObject.Parse(jsonData); var jsonObject = JObject.Parse(jsonData);
//Console.WriteLine(jsonObject); //Console.WriteLine(jsonObject);
// Console.WriteLine(jsonObject["GridMeter"]);
if (jsonObject["Battery"] != null && jsonObject["Battery"]["Soc"] != null) if (jsonObject["Battery"] != null && jsonObject["Battery"]["Soc"] != null)
{ {
batterySoc.Add((double)jsonObject["Battery"]["Soc"]); batterySoc.Add((double)jsonObject["Battery"]["Soc"]);
@ -166,86 +163,11 @@ public static class Aggregator
Console.WriteLine("power import is "+jsonObject["GridMeter"]["ActivePowerImportT3"]); Console.WriteLine("power import is "+jsonObject["GridMeter"]["ActivePowerImportT3"]);
gridPowerImport.Add((double)jsonObject["GridMeter"]["ActivePowerImportT3"]); gridPowerImport.Add((double)jsonObject["GridMeter"]["ActivePowerImportT3"]);
} }
if (jsonObject["Battery"] != null && jsonObject["Battery"]["HeatingPower"] != null)
{
heatingPower.Add((double)jsonObject["Battery"]["HeatingPower"]);
}
} }
catch (Exception e) catch (Exception e)
{ {
Console.WriteLine($"Failed to parse JSON file {jsonFile}: {e.Message}"); Console.WriteLine($"Failed to parse JSON file {jsonFile}: {e.Message}");
} }
// using var reader = new StreamReader(jsonFile);
//
// while (!reader.EndOfStream)
// {
//
// var line = reader.ReadLine();
// var lines = line?.Split(';');
//
// // Assuming there are always three columns (variable name and its value)
// if (lines is { Length: 3 })
// {
// var variableName = lines[0].Trim();
//
// if (TryParse(lines[1].Trim(), out var value))
// {
// switch (variableName)
// {
// case "/Battery/Soc":
// batterySoc.Add(value);
// break;
//
// case "/PvOnDc/DcWh" :
// pvPowerSum.Add(value);
// break;
//
// case "/Battery/Dc/Power":
//
// if (value < 0)
// {
// batteryDischargePower.Add(value);
// }
// else
// {
// batteryChargePower.Add(value);
//
// }
// break;
//
// case "/GridMeter/ActivePowerExportT3":
// // we are using different register to check which value from the grid meter we need to use
// // At the moment register 8002 amd 8012. in KWh
// gridPowerExport.Add(value);
// break;
// case "/GridMeter/ActivePowerImportT3":
// gridPowerImport.Add(value);
// break;
// case "/Battery/HeatingPower":
// heatingPower.Add(value);
// break;
// // Add more cases as needed
// default:
// // Code to execute when variableName doesn't match any condition
// break;
// }
//
// }
// else
// {
// //Handle cases where variableValue is not a valid number
// // Console.WriteLine(
// // $"Invalid numeric value for variable {variableName}:{lines[1].Trim()}");
// }
// }
// else
// {
// // Handle invalid column format
// //Console.WriteLine("Invalid format in column");
// }
//}
} }
} }
@ -262,7 +184,6 @@ public static class Aggregator
var dischargingEnergy = (batteryDischargePower.Any() ? batteryDischargePower.Average() : 0.0) / 3600; var dischargingEnergy = (batteryDischargePower.Any() ? batteryDischargePower.Average() : 0.0) / 3600;
var chargingEnergy = (batteryChargePower.Any() ? batteryChargePower.Average() : 0.0) / 3600; var chargingEnergy = (batteryChargePower.Any() ? batteryChargePower.Average() : 0.0) / 3600;
var heatingPowerAvg = (heatingPower.Any() ? heatingPower.Average() : 0.0) / 3600;
var dMaxSoc = batterySoc.Any() ? batterySoc.Max() : 0.0; var dMaxSoc = batterySoc.Any() ? batterySoc.Max() : 0.0;
var dMinSoc = batterySoc.Any() ? batterySoc.Min() : 0.0; var dMinSoc = batterySoc.Any() ? batterySoc.Min() : 0.0;
@ -280,7 +201,7 @@ public static class Aggregator
GridExportPower = dSumGridExportPower, GridExportPower = dSumGridExportPower,
GridImportPower = dSumGridImportPower, GridImportPower = dSumGridImportPower,
PvPower = dSumPvPower, PvPower = dSumPvPower,
HeatingPower = heatingPowerAvg HeatingPower = 0
}; };
// Print the stored JSON data for verification // Print the stored JSON data for verification

View File

@ -1,8 +1,13 @@
import React, { useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
Card, Card,
CircularProgress, CircularProgress,
FormControl,
Grid, Grid,
InputAdornment,
InputLabel,
MenuItem,
Select,
styled, styled,
Table, Table,
TableBody, TableBody,
@ -10,6 +15,7 @@ import {
TableContainer, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
TextField,
Typography, Typography,
useTheme useTheme
} from '@mui/material'; } from '@mui/material';
@ -19,42 +25,23 @@ import { useLocation, useNavigate } from 'react-router-dom';
import routes from '../../../Resources/routes.json'; import routes from '../../../Resources/routes.json';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import BuildIcon from '@mui/icons-material/Build'; import BuildIcon from '@mui/icons-material/Build';
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
interface FlatInstallationViewProps { interface FlatInstallationViewProps {
installations: I_Installation[]; installations: I_Installation[];
} }
const FlatInstallationView = (props: FlatInstallationViewProps) => { const FlatInstallationView = (props: FlatInstallationViewProps) => {
// const webSocketContext = useContext(WebSocketContext);
// const { getSortedInstallations } = webSocketContext;
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1); const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
const currentLocation = useLocation(); const currentLocation = useLocation();
// const BATCH_SIZE = 50;
const sortedInstallations = [...props.installations].sort((a, b) => { const [visibleCount, setVisibleCount] = useState(BATCH_SIZE);
// Compare the status field of each installation and sort them based on the status. const observerRef = useRef<IntersectionObserver | null>(null);
//Installations with alarms go first const loadMoreRef = useRef<HTMLDivElement>(null);
let a_status = a.status; const [searchTerm, setSearchTerm] = useState('');
let b_status = b.status; const [sortByStatus, setSortByStatus] = useState('All Installations');
const [sortByAction, setSortByAction] = useState('All Installations');
if (a_status > b_status) {
return -1;
}
if (a_status < b_status) {
return 1;
}
return 0;
});
// const sortedInstallations = useMemo(() => {
// return [...props.installations].sort((a, b) => {
// const a_status = getStatus(a.id) || 0;
// const b_status = getStatus(b.id) || 0;
// return b_status - a_status;
// });
// }, [props.installations, getStatus]);
// const sortedInstallations = getSortedInstallations();
const handleSelectOneInstallation = (installationID: number): void => { const handleSelectOneInstallation = (installationID: number): void => {
if (selectedInstallation != installationID) { if (selectedInstallation != installationID) {
@ -86,265 +73,454 @@ const FlatInstallationView = (props: FlatInstallationViewProps) => {
} }
})); }));
const indexedData = useMemo(() => {
return props.installations.map((item) => ({
...item,
nameLower: item.name.toLowerCase(),
locationLower: item.location.toLowerCase(),
regionLower: item.region.toLowerCase()
}));
}, [props.installations]);
const sortedInstallations = useMemo(() => {
let filtered = indexedData.filter(
(item) =>
item.nameLower.includes(searchTerm.toLowerCase()) ||
item.locationLower.includes(searchTerm.toLowerCase()) ||
item.regionLower.includes(searchTerm.toLowerCase())
);
// Apply the 'showOnly' filter
switch (sortByStatus) {
case 'Installations With Alarm':
filtered = filtered.filter((i) => i.status === 2);
break;
case 'Installations with Warning':
filtered = filtered.filter((i) => i.status === 1);
break;
case 'Functional Installations':
filtered = filtered.filter((i) => i.status === 0);
break;
case 'Offline Installations':
filtered = filtered.filter((i) => i.status === -1);
break;
}
switch (sortByAction) {
case 'Installations With Action Flag':
filtered = filtered.filter((i) => i.testingMode === true);
break;
case 'Installations Without Action Flag':
filtered = filtered.filter((i) => i.testingMode === false);
break;
}
// Sort by status (alarms first)
return filtered.sort((a, b) => {
const a_status = a.status;
const b_status = b.status;
if (a_status > b_status) return -1;
if (a_status < b_status) return 1;
return 0;
});
}, [searchTerm, indexedData, sortByAction, sortByStatus]);
useEffect(() => {
if (observerRef.current) {
observerRef.current.disconnect();
}
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setVisibleCount((prev) => prev + BATCH_SIZE);
}
},
{
rootMargin: '600px', // triggers before the element fully enters view
threshold: 0.5 // triggers when 10% of it is visible
}
);
if (loadMoreRef.current) {
observerRef.current.observe(loadMoreRef.current);
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, []);
return ( return (
<Grid container spacing={1} sx={{ marginTop: 0.1 }}> <>
<Grid <Grid container spacing={1}>
item <Grid
sx={{ item
display: xs={12}
currentLocation.pathname === md={6}
routes.salidomo_installations + 'list' || sx={{
currentLocation.pathname === display:
routes.salidomo_installations + routes.list currentLocation.pathname ===
? 'block' routes.salidomo_installations + 'list' ||
: 'none' currentLocation.pathname ===
}} routes.salidomo_installations + routes.list
> ? 'block'
<Card> : 'none'
<TableContainer> }}
<Table> >
<TableHead> <div
<TableRow> style={{
<TableCell> display: 'flex',
<FormattedMessage id="name" defaultMessage="Name" /> flexDirection: 'row',
</TableCell> alignItems: 'center',
<TableCell> gap: '16px', // spacing between elements
<FormattedMessage id="location" defaultMessage="Location" /> width: '100%'
</TableCell> }}
<TableCell> >
<FormattedMessage id="region" defaultMessage="Region" /> <FormControl sx={{ flex: 1 }}>
</TableCell> <TextField
<TableCell> placeholder="Search"
<FormattedMessage id="country" defaultMessage="Country" /> value={searchTerm}
</TableCell> onChange={(e) => setSearchTerm(e.target.value)}
<TableCell> fullWidth
<FormattedMessage id="VRM Link" defaultMessage="VRM Link" /> InputProps={{
</TableCell> startAdornment: (
<TableCell> <InputAdornment position="start">
<FormattedMessage id="Device" defaultMessage="Device" /> <SearchTwoToneIcon />
</TableCell> </InputAdornment>
<TableCell> )
<FormattedMessage id="status" defaultMessage="Status" /> }}
</TableCell> />
</TableRow> </FormControl>
</TableHead>
<TableBody>
{sortedInstallations
// .filter(
// (installation) =>
// installation.status === -1 &&
// installation.testingMode == false
// )
.map((installation) => {
const isInstallationSelected =
installation.id === selectedInstallation;
const status = installation.status; <FormControl sx={{ width: 240 }}>
<InputLabel>
<FormattedMessage
id="sortByStatus"
defaultMessage="Sort By Status"
/>
</InputLabel>
<Select
value={sortByStatus}
onChange={(e) => setSortByStatus(e.target.value)}
label="Show Only"
>
{[
'All Installations',
'Installations With Alarm',
'Installations with Warning',
'Functional Installations',
'Offline Installations'
].map((type) => (
<MenuItem key={type} value={type}>
{type}
</MenuItem>
))}
</Select>
</FormControl>
return ( <FormControl sx={{ width: 240 }}>
<HoverableTableRow <InputLabel>
key={installation.id} <FormattedMessage
onClick={() => id="sortByActionFlag"
handleSelectOneInstallation(installation.id) defaultMessage="Sort By Action Flag"
} />
> </InputLabel>
<TableCell> <Select
<Typography value={sortByAction}
variant="body2" onChange={(e) => setSortByAction(e.target.value)}
fontWeight="bold" label="Show Only"
color="text.primary" >
gutterBottom {[
noWrap 'All Installations',
sx={{ marginTop: '10px', fontSize: 'small' }} 'Installations With Action Flag',
> 'Installations Without Action Flag'
{installation.name} ].map((type) => (
</Typography> <MenuItem key={type} value={type}>
</TableCell> {type}
</MenuItem>
))}
</Select>
</FormControl>
</div>
</Grid>
</Grid>
<TableCell> <Grid container spacing={1} sx={{ marginTop: 0.1 }}>
<Typography <Grid
variant="body2" item
fontWeight="bold" sx={{
color="text.primary" display:
gutterBottom currentLocation.pathname ===
noWrap routes.salidomo_installations + 'list' ||
sx={{ marginTop: '10px', fontSize: 'small' }} currentLocation.pathname ===
> routes.salidomo_installations + routes.list
{installation.location} ? 'block'
</Typography> : 'none'
</TableCell> }}
>
<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>
<TableCell>
<FormattedMessage id="Device" defaultMessage="Device" />
</TableCell>
<TableCell>
<FormattedMessage id="status" defaultMessage="Status" />
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedInstallations
.slice(0, visibleCount)
<TableCell> // .filter(
<Typography // (installation) =>
variant="body2" // installation.status === -1 &&
fontWeight="bold" // installation.testingMode == false
color="text.primary" // )
gutterBottom .map((installation) => {
noWrap const isInstallationSelected =
sx={{ marginTop: '10px', fontSize: 'small' }} installation.id === selectedInstallation;
>
{installation.region}
</Typography>
</TableCell>
<TableCell> const status = installation.status;
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
{installation.country}
</Typography>
</TableCell>
<TableCell> return (
<Typography <HoverableTableRow
variant="body2" key={installation.id}
fontWeight="bold" onClick={() =>
color="text.primary" handleSelectOneInstallation(installation.id)
gutterBottom }
noWrap >
sx={{ marginTop: '10px', fontSize: 'small' }} <TableCell>
> <Typography
<a variant="body2"
href={ fontWeight="bold"
'https://vrm.victronenergy.com/installation/' + color="text.primary"
installation.vrmLink + gutterBottom
'/dashboard' noWrap
} sx={{ marginTop: '10px', fontSize: 'small' }}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'inline-block',
padding: '0'
}} // Style the link
onClick={(e) => e.stopPropagation()} // Prevent the click event from bubbling up to the table row
> >
VRM link {installation.name}
</a> </Typography>
</Typography> </TableCell>
</TableCell>
<TableCell> <TableCell>
<div <Typography
style={{ variant="body2"
display: 'flex', fontWeight="bold"
alignItems: 'center', color="text.primary"
marginLeft: '5px' gutterBottom
}} noWrap
> sx={{ marginTop: '10px', fontSize: 'small' }}
{installation.device === 1 ? ( >
<Typography {installation.location}
variant="body2" </Typography>
fontWeight="bold" </TableCell>
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
Cerbo
</Typography>
) : installation.device === 2 ? (
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
Venus
</Typography>
) : (
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
Device not specified
</Typography>
)}
</div>
</TableCell>
<TableCell> <TableCell>
<div <Typography
style={{ variant="body2"
display: 'flex', fontWeight="bold"
alignItems: 'center', color="text.primary"
marginLeft: '15px' gutterBottom
}} noWrap
> sx={{ marginTop: '10px', fontSize: 'small' }}
{status === -1 ? ( >
<CancelIcon {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={
'https://vrm.victronenergy.com/installation/' +
installation.vrmLink +
'/dashboard'
}
target="_blank"
rel="noopener noreferrer"
style={{ style={{
width: '23px', display: 'inline-block',
height: '23px', padding: '0'
color: 'red', }} // Style the link
borderRadius: '50%' onClick={(e) => e.stopPropagation()} // Prevent the click event from bubbling up to the table row
}} >
/> VRM link
) : ( </a>
'' </Typography>
)} </TableCell>
{status === -2 ? (
<CircularProgress
size={20}
sx={{
color: '#f7b34d'
}}
/>
) : (
''
)}
<TableCell>
<div <div
style={{ style={{
width: '20px', display: 'flex',
height: '20px', alignItems: 'center',
marginLeft: '2px', marginLeft: '5px'
borderRadius: '50%',
backgroundColor:
status === 2
? 'red'
: status === 1
? 'orange'
: status === -1 || status === -2
? 'transparent'
: 'green'
}} }}
/> >
{installation.testingMode && ( {installation.device === 1 ? (
<BuildIcon <Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
Cerbo
</Typography>
) : installation.device === 2 ? (
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
Venus
</Typography>
) : (
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
Device not specified
</Typography>
)}
</div>
</TableCell>
<TableCell>
<div
style={{
display: 'flex',
alignItems: 'center',
marginLeft: '15px'
}}
>
{status === -1 ? (
<CancelIcon
style={{
width: '23px',
height: '23px',
color: 'red',
borderRadius: '50%'
}}
/>
) : (
''
)}
{status === -2 ? (
<CircularProgress
size={20}
sx={{
color: '#f7b34d'
}}
/>
) : (
''
)}
<div
style={{ style={{
width: '23px', width: '20px',
height: '23px', height: '20px',
color: 'purple', marginLeft: '2px',
borderRadius: '50%', borderRadius: '50%',
position: 'relative', backgroundColor:
zIndex: 1, status === 2
marginLeft: status != -1 ? '25px' : '0px' ? 'red'
: status === 1
? 'orange'
: status === -1 || status === -2
? 'transparent'
: 'green'
}} }}
/> />
)} {installation.testingMode && (
</div> <BuildIcon
</TableCell> style={{
</HoverableTableRow> width: '23px',
); height: '23px',
})} color: 'purple',
</TableBody> borderRadius: '50%',
</Table> position: 'relative',
</TableContainer> zIndex: 1,
</Card> marginLeft: status != -1 ? '25px' : '0px'
}}
/>
)}
</div>
</TableCell>
</HoverableTableRow>
);
})}
<TableRow>
<TableCell colSpan={7} align="center">
<div ref={loadMoreRef} style={{ height: '40px' }} />
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</Card>
</Grid>
</Grid> </Grid>
</Grid> </>
); );
}; };

View File

@ -1,9 +1,7 @@
import React, { useMemo, useState } from 'react'; import React from 'react';
import { FormControl, Grid, InputAdornment, TextField } from '@mui/material';
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
import FlatInstallationView from './FlatInstallationView'; import FlatInstallationView from './FlatInstallationView';
import { I_Installation } from '../../../interfaces/InstallationTypes'; import { I_Installation } from '../../../interfaces/InstallationTypes';
import { Route, Routes, useLocation } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import routes from '../../../Resources/routes.json'; import routes from '../../../Resources/routes.json';
import SalidomoInstallation from './Installation'; import SalidomoInstallation from './Installation';
@ -12,74 +10,11 @@ interface installationSearchProps {
} }
function InstallationSearch(props: installationSearchProps) { function InstallationSearch(props: installationSearchProps) {
const [searchTerm, setSearchTerm] = useState('');
const currentLocation = useLocation();
// const [filteredData, setFilteredData] = useState(props.installations);
const indexedData = useMemo(() => {
return props.installations.map((item) => ({
...item,
nameLower: item.name.toLowerCase(),
locationLower: item.location.toLowerCase(),
regionLower: item.region.toLowerCase()
}));
}, [props.installations]);
const filteredData = useMemo(() => {
return indexedData.filter(
(item) =>
item.nameLower.includes(searchTerm.toLowerCase()) ||
item.locationLower.includes(searchTerm.toLowerCase()) ||
item.regionLower.includes(searchTerm.toLowerCase())
);
}, [searchTerm, indexedData]);
return ( return (
<> <>
<Grid container> <FlatInstallationView installations={props.installations} />
<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'
}}
>
<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> <Routes>
{filteredData.map((installation) => { {props.installations.map((installation) => {
return ( return (
<Route <Route
key={installation.id} key={installation.id}