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);
//Console.WriteLine(jsonObject);
// Console.WriteLine(jsonObject["GridMeter"]);
if (jsonObject["Battery"] != null && jsonObject["Battery"]["Soc"] != null)
{
batterySoc.Add((double)jsonObject["Battery"]["Soc"]);
@ -166,86 +163,11 @@ public static class Aggregator
Console.WriteLine("power import is "+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)
{
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 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 dMinSoc = batterySoc.Any() ? batterySoc.Min() : 0.0;
@ -280,7 +201,7 @@ public static class Aggregator
GridExportPower = dSumGridExportPower,
GridImportPower = dSumGridImportPower,
PvPower = dSumPvPower,
HeatingPower = heatingPowerAvg
HeatingPower = 0
};
// 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 {
Card,
CircularProgress,
FormControl,
Grid,
InputAdornment,
InputLabel,
MenuItem,
Select,
styled,
Table,
TableBody,
@ -10,6 +15,7 @@ import {
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
useTheme
} from '@mui/material';
@ -19,42 +25,23 @@ import { useLocation, useNavigate } from 'react-router-dom';
import routes from '../../../Resources/routes.json';
import CancelIcon from '@mui/icons-material/Cancel';
import BuildIcon from '@mui/icons-material/Build';
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
interface FlatInstallationViewProps {
installations: I_Installation[];
}
const FlatInstallationView = (props: FlatInstallationViewProps) => {
// const webSocketContext = useContext(WebSocketContext);
// const { getSortedInstallations } = webSocketContext;
const navigate = useNavigate();
const [selectedInstallation, setSelectedInstallation] = useState<number>(-1);
const currentLocation = useLocation();
//
const sortedInstallations = [...props.installations].sort((a, b) => {
// Compare the status field of each installation and sort them based on the status.
//Installations with alarms go first
let a_status = a.status;
let b_status = b.status;
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 BATCH_SIZE = 50;
const [visibleCount, setVisibleCount] = useState(BATCH_SIZE);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortByStatus, setSortByStatus] = useState('All Installations');
const [sortByAction, setSortByAction] = useState('All Installations');
const handleSelectOneInstallation = (installationID: number): void => {
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 (
<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>
<TableCell>
<FormattedMessage id="Device" defaultMessage="Device" />
</TableCell>
<TableCell>
<FormattedMessage id="status" defaultMessage="Status" />
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedInstallations
// .filter(
// (installation) =>
// installation.status === -1 &&
// installation.testingMode == false
// )
.map((installation) => {
const isInstallationSelected =
installation.id === selectedInstallation;
<>
<Grid container spacing={1}>
<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: 'row',
alignItems: 'center',
gap: '16px', // spacing between elements
width: '100%'
}}
>
<FormControl sx={{ flex: 1 }}>
<TextField
placeholder="Search"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchTwoToneIcon />
</InputAdornment>
)
}}
/>
</FormControl>
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 (
<HoverableTableRow
key={installation.id}
onClick={() =>
handleSelectOneInstallation(installation.id)
}
>
<TableCell>
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
{installation.name}
</Typography>
</TableCell>
<FormControl sx={{ width: 240 }}>
<InputLabel>
<FormattedMessage
id="sortByActionFlag"
defaultMessage="Sort By Action Flag"
/>
</InputLabel>
<Select
value={sortByAction}
onChange={(e) => setSortByAction(e.target.value)}
label="Show Only"
>
{[
'All Installations',
'Installations With Action Flag',
'Installations Without Action Flag'
].map((type) => (
<MenuItem key={type} value={type}>
{type}
</MenuItem>
))}
</Select>
</FormControl>
</div>
</Grid>
</Grid>
<TableCell>
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
{installation.location}
</Typography>
</TableCell>
<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>
<TableCell>
<FormattedMessage id="Device" defaultMessage="Device" />
</TableCell>
<TableCell>
<FormattedMessage id="status" defaultMessage="Status" />
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedInstallations
.slice(0, visibleCount)
<TableCell>
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
{installation.region}
</Typography>
</TableCell>
// .filter(
// (installation) =>
// installation.status === -1 &&
// installation.testingMode == false
// )
.map((installation) => {
const isInstallationSelected =
installation.id === selectedInstallation;
<TableCell>
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
{installation.country}
</Typography>
</TableCell>
const status = installation.status;
<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={{
display: 'inline-block',
padding: '0'
}} // Style the link
onClick={(e) => e.stopPropagation()} // Prevent the click event from bubbling up to the table row
return (
<HoverableTableRow
key={installation.id}
onClick={() =>
handleSelectOneInstallation(installation.id)
}
>
<TableCell>
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
VRM link
</a>
</Typography>
</TableCell>
{installation.name}
</Typography>
</TableCell>
<TableCell>
<div
style={{
display: 'flex',
alignItems: 'center',
marginLeft: '5px'
}}
>
{installation.device === 1 ? (
<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>
<Typography
variant="body2"
fontWeight="bold"
color="text.primary"
gutterBottom
noWrap
sx={{ marginTop: '10px', fontSize: 'small' }}
>
{installation.location}
</Typography>
</TableCell>
<TableCell>
<div
style={{
display: 'flex',
alignItems: 'center',
marginLeft: '15px'
}}
>
{status === -1 ? (
<CancelIcon
<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={
'https://vrm.victronenergy.com/installation/' +
installation.vrmLink +
'/dashboard'
}
target="_blank"
rel="noopener noreferrer"
style={{
width: '23px',
height: '23px',
color: 'red',
borderRadius: '50%'
}}
/>
) : (
''
)}
{status === -2 ? (
<CircularProgress
size={20}
sx={{
color: '#f7b34d'
}}
/>
) : (
''
)}
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
</a>
</Typography>
</TableCell>
<TableCell>
<div
style={{
width: '20px',
height: '20px',
marginLeft: '2px',
borderRadius: '50%',
backgroundColor:
status === 2
? 'red'
: status === 1
? 'orange'
: status === -1 || status === -2
? 'transparent'
: 'green'
display: 'flex',
alignItems: 'center',
marginLeft: '5px'
}}
/>
{installation.testingMode && (
<BuildIcon
>
{installation.device === 1 ? (
<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={{
width: '23px',
height: '23px',
color: 'purple',
width: '20px',
height: '20px',
marginLeft: '2px',
borderRadius: '50%',
position: 'relative',
zIndex: 1,
marginLeft: status != -1 ? '25px' : '0px'
backgroundColor:
status === 2
? 'red'
: status === 1
? 'orange'
: status === -1 || status === -2
? 'transparent'
: 'green'
}}
/>
)}
</div>
</TableCell>
</HoverableTableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Card>
{installation.testingMode && (
<BuildIcon
style={{
width: '23px',
height: '23px',
color: 'purple',
borderRadius: '50%',
position: 'relative',
zIndex: 1,
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>
</>
);
};

View File

@ -1,9 +1,7 @@
import React, { useMemo, useState } from 'react';
import { FormControl, Grid, InputAdornment, TextField } from '@mui/material';
import SearchTwoToneIcon from '@mui/icons-material/SearchTwoTone';
import React from 'react';
import FlatInstallationView from './FlatInstallationView';
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 SalidomoInstallation from './Installation';
@ -12,74 +10,11 @@ interface 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 (
<>
<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'
}}
>
<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} />
<FlatInstallationView installations={props.installations} />
<Routes>
{filteredData.map((installation) => {
{props.installations.map((installation) => {
return (
<Route
key={installation.id}