frontend/src/pages/DeviceComponent.tsx
csawatzky 23223aa1a8 updated the api to use a new variable for whether to load all nodes, this will still use the show errors boolean for the component page
fixed issue in the filter function in the grain cable model that caused it to return empty when a top node was not set
updated the bin svg to have the disabled nodes greyed out
2025-07-31 13:20:31 -06:00

742 lines
24 KiB
TypeScript

import { Box, Grid2 as Grid, Skeleton, Theme, Tooltip, Typography } from "@mui/material";
import { makeStyles } from "@mui/styles";
import { NextMeasurementChip } from "common/NextMeasurementChip";
import ResponsiveTable, { Column } from "common/ResponsiveTable";
// import { getTableIcons } from "common/ResponsiveTable";
import SmartBreadcrumb from "common/SmartBreadcrumb";
import StatusChip from "common/StatusChip";
import { GetDefaultDateRange } from "common/time/DateRange";
import ComponentActions from "component/ComponentActions";
import ComponentChart from "component/ComponentChart";
import ComponentTypeChip from "component/ComponentTypeChip";
import UnitMeasurementSummary from "component/UnitMeasurementSummary";
// import MeasurementSummary from "component/MeasurementSummary";
// import UnitMeasurementSummary from "component/UnitMeasurementSummary";
import {
useComponentAPI,
useDeviceAPI,
useGroupAPI,
useInteractionsAPI,
usePrevious,
useSnackbar,
useUserAPI
} from "hooks";
import InteractionChip from "interactions/InteractionChip";
import InteractionSettings from "interactions/InteractionSettings";
import { cloneDeep } from "lodash";
// import MaterialTable from "material-table";
import { Component, Device, deviceScope, Group, Interaction } from "models";
import { convertedUnitMeasurement, MeasurementsFor, UnitMeasurement } from "models/UnitMeasurement";
import moment, { Moment } from "moment";
// import { MatchParams } from "navigation/Routes";
import PageContainer from "pages/PageContainer";
import { getContextKeys, getContextTypes } from "pbHelpers/Context";
import {
DeviceAvailabilityMap,
FindAvailablePositions,
OffsetAvailabilityMap
} from "pbHelpers/DeviceAvailability";
import { getDefaultInteraction } from "pbHelpers/Interaction";
import { pond } from "protobuf-ts/pond";
import { useGlobalState, useTeamAPI } from "providers";
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router-dom";
// import { useRouteMatch } from "react-router";
import { or } from "utils/types";
const useStyles = makeStyles((theme: Theme) => {
return ({
toolbar: {
marginTop: theme.spacing(1),
paddingRight: theme.spacing(1),
paddingLeft: theme.spacing(1),
[theme.breakpoints.up("sm")]: {
paddingRight: theme.spacing(1),
paddingLeft: theme.spacing(2)
}
},
toolbarTitle: {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis"
},
contentContainer: {
padding: theme.spacing(1),
[theme.breakpoints.up("sm")]: {
paddingRight: theme.spacing(2)
},
[theme.breakpoints.up("lg")]: {
paddingRight: theme.spacing(3)
}
},
overviewContainer: {
position: "relative",
maxWidth: "100%",
overflowX: "auto",
marginBottom: theme.spacing(2)
},
verticalSpacing: {
marginBottom: theme.spacing(2)
},
tablePagination: {
overflow: "auto"
}
})
});
export default function DeviceComponent() {
const classes = useStyles();
// const match = useRouteMatch<MatchParams>();
const deviceAPI = useDeviceAPI();
const componentAPI = useComponentAPI();
const { error } = useSnackbar();
const groupAPI = useGroupAPI();
const interactionsAPI = useInteractionsAPI();
const userAPI = useUserAPI();
const teamAPI = useTeamAPI();
// const deviceID = parseInt(match.params.deviceID, 10);
// const componentID = match.params.componentID;
// const groupID = or(parseInt(match.params.groupID, 10), undefined);
const deviceID = +(useParams<{ deviceID: string }>()?.deviceID ?? "0");
const componentID = useParams<{ componentID: string }>()?.componentID ?? "";
const groupID = +(useParams<{ groupID: string }>()?.groupID ?? "");
const [isFirstRender, setIsFirstRender] = useState<boolean>(true);
const [loadingInitial, setLoadingInitial] = useState<boolean>(false);
const defaultDateRange = GetDefaultDateRange();
const [startDate, setStartDate] = useState<Moment>(defaultDateRange.start);
const prevStartDate = usePrevious(startDate);
const [endDate, setEndDate] = useState<Moment>(defaultDateRange.end);
const prevEndDate = usePrevious(endDate);
const live = useRef(defaultDateRange.live);
const [device, setDevice] = useState<Device>(Device.create());
const [group, setGroup] = useState<Group | undefined>(Group.create());
const [component, setComponent] = useState<Component>(Component.create());
const [components, setComponents] = useState<Component[]>([]);
const [sampling, setSampling] = useState(false);
const liveSamples = useRef<pond.Measurement[]>([]);
const [samples, setSamples] = useState<pond.Measurement[]>([]);
const [permissions, setPermissions] = useState<pond.Permission[]>([]);
const [interactions, setInteractions] = useState<Interaction[]>([]);
const [selectedInteraction, setSelectedInteraction] = useState<Interaction>(
getDefaultInteraction()
);
const [pageSize, setPageSize] = useState<number>(5);
const [page, setPage] = useState(0)
const [order, setOrder] = useState("desc")
const [rows, setRows] = useState<convertedUnitMeasurement[]>([])
const [total, setTotal] = useState<number>(0)
const prevRowsPerPage = usePrevious(pageSize);
const [availablePositions, setAvailablePositions] = useState<DeviceAvailabilityMap>(new Map());
const [availableOffsets, setAvailableOffsets] = useState<OffsetAvailabilityMap>(new Map());
const tableRef: any = useRef();
let isInteractionSettingsOpen =
JSON.stringify(or(selectedInteraction, getDefaultInteraction())) !==
JSON.stringify(getDefaultInteraction());
const [{ user, as, showErrors }] = useGlobalState();
const [unitMeasurements, setUnitMeasurements] = useState<UnitMeasurement[]>([]);
const [rm, setRM] = useState<pond.Measurement>(pond.Measurement.create());
const [recentUnitMeasurement, setRecentUnitMeasurement] = useState<UnitMeasurement[]>([]);
const [recentGoodUnitMeasurement, setRecentGoodUnitMeasurement] = useState<UnitMeasurement[]>([]);
const sampleLimit = 200;
const [deviceComponentPrefs, setDeviceComponentPrefs] = useState<
pond.DeviceComponentPreferences
>();
const setDefaultState = () => {
setDevice(Device.create());
setGroup(undefined);
setInteractions([]);
setSelectedInteraction(getDefaultInteraction());
setComponents([]);
setComponent(Component.create());
setPermissions([]);
setAvailablePositions(new Map());
setAvailableOffsets(new Map());
};
const loadUnitMeasurements = useCallback(() => {
setUnitMeasurements([]);
setSampling(true);
componentAPI
.sampleUnitMeasurements(
deviceID,
componentID,
startDate,
endDate,
sampleLimit,
false,
getContextKeys(),
getContextTypes(),
showErrors,
showErrors,
as,
)
.then(resp => {
if (resp.data.measurements) {
let newUnitMeasurements = resp.data.measurements.map((um: any) =>
UnitMeasurement.any(um, user)
);
setUnitMeasurements([...newUnitMeasurements]);
}
})
.finally(() => {
setSampling(false);
});
}, [componentAPI, deviceID, componentID, user, startDate, endDate, showErrors, as]);
useEffect(() => {
if (live.current && liveSamples) {
setSamples(liveSamples.current);
} else {
loadUnitMeasurements();
}
}, [loadUnitMeasurements]);
useEffect(() => {
if (component.key() !== "") {
interactionsAPI
.listInteractionsByComponent(deviceID, component.location(), undefined, as)
.then((rInteractions: Interaction[]) => {
setInteractions(rInteractions);
})
.catch((err: any) => {
console.log(err);
});
}
}, [component, deviceID, interactionsAPI]);
const loadState = useCallback(() => {
if (user.settings.id === "") return;
setLoadingInitial(true);
deviceAPI
.get(deviceID, false, getContextKeys().slice(0, -1), getContextTypes().slice(0, -1), as)
.then((response: any) => {
const rDevice = Device.any(response.data);
setDevice(rDevice);
let componentsPromise = componentAPI.list(
deviceID,
false,
getContextKeys(),
getContextTypes(),
true,
as
);
let userPromise = userAPI.getUser(user.settings.id, deviceScope(deviceID.toString()));
let prefPromise = deviceAPI.listDeviceComponentPreferences(deviceID, getContextKeys().slice(0, -1), getContextTypes().slice(0, -1), as);
Promise.all([componentsPromise, userPromise, prefPromise])
.then((responses: any) => {
const rawComponents: Array<any> = or(responses[0].data.components, []).sort(
(a: any, b: any) => {
if (or(a.settings.name, "").toLowerCase() < or(b.settings.name, "").toLowerCase()) {
return -1;
} else if (
or(a.settings.name, "").toLowerCase() > or(b.settings.name, "").toLowerCase()
) {
return 1;
} else {
return 0;
}
}
);
let rComponents: Component[] = [];
rawComponents.forEach((rawComponent: any) => {
rComponents.push(Component.any(rawComponent));
});
setComponents(rComponents);
let rComponent = rComponents.find(c => componentID === c.key());
setComponent(rComponent ? rComponent : Component.create());
if (rComponent?.status.lastMeasurement) {
setRM(rComponent.status.lastMeasurement);
}
if (rComponent?.status.measurement) {
let measurements = rComponent.status.measurement.map(um =>
UnitMeasurement.any(um, user)
);
setRecentUnitMeasurement(measurements);
}
if (rComponent?.status?.lastGoodMeasurement) {
let measurements = rComponent.status.lastGoodMeasurement.map(um =>
UnitMeasurement.any(um, user)
);
setRecentGoodUnitMeasurement(measurements);
}
setPermissions(responses[1].permissions);
let available = FindAvailablePositions(rComponents, rDevice.settings.product);
setAvailablePositions(available.GetAvailability());
setAvailableOffsets(available.offsetAvailability);
let allPrefs = responses[2].data.preferences;
Object.keys(allPrefs).forEach(compoundKey => {
let componentKey = compoundKey.split(":")[1]
? compoundKey.split(":")[1]
: compoundKey;
if (componentID === componentKey) {
setDeviceComponentPrefs(
pond.DeviceComponentPreferences.fromObject(allPrefs[compoundKey])
);
}
});
})
.catch(err => {
console.log(err);
setDefaultState();
})
.finally(() => {
setLoadingInitial(false);
if (groupID > 0) {
groupAPI
.getGroup(groupID)
.then((response: any) => {
setGroup(Group.any(response.data));
})
.catch((error: any) => {
setGroup(undefined);
});
}
if (as) {
teamAPI.getTeam(as, deviceScope(deviceID.toString())).then(resp => {
//if (resp.permissions.length > permissions.length) {
setPermissions(resp.permissions);
//}
});
}
});
})
.catch((err: any) => {
setDefaultState();
console.log(err);
});
}, [
componentAPI,
componentID,
deviceAPI,
deviceID,
groupAPI,
groupID,
userAPI,
as,
teamAPI,
user
]);
useEffect(() => {
if (isFirstRender) {
loadState();
setIsFirstRender(false);
} else if (prevStartDate !== startDate || prevEndDate !== endDate) {
if (tableRef && tableRef.current) {
tableRef.current.onChangePage(undefined, 0);
}
}
}, [
component,
endDate,
isFirstRender,
loadState,
prevEndDate,
prevRowsPerPage,
prevStartDate,
pageSize,
startDate,
tableRef
]);
const interactionChips = (
component: Component,
interactions: Interaction[],
components: Component[]
) => {
if (!component || !interactions || !components || interactions.length <= 0) {
return null;
}
let chips: any = [];
interactions.forEach((interaction: Interaction) => {
let rawInteraction = Interaction.clone(interaction);
let key =
JSON.stringify(rawInteraction.settings.source) +
JSON.stringify(rawInteraction.settings.sink) +
rawInteraction.settings.instance;
chips.push(
<Grid key={key}>
<InteractionChip
interaction={interaction}
component={component}
components={components}
onClick={() => selectInteraction(rawInteraction)}
/>
</Grid>
);
});
return chips;
};
const status = () => {
if (loadingInitial)
return (
<Box width={1} margin={1}>
<Skeleton variant="text" width="50%" />
</Box>
);
return (
<Grid
container
spacing={1}
flex-direction="row"
justifyContent="flex-start"
alignItems="center"
wrap="nowrap"
className={classes.overviewContainer}>
{!component.status.synced && (
<Grid >
<StatusChip key="status" status="pending" />
</Grid>
)}
<Grid >
{/* TODO: use the unit measurement timestamp */}
<NextMeasurementChip
lastMeasurement={or(rm, { timestamp: "" }).timestamp}
periodMs={component.settings.reportPeriodMs}
/>
</Grid>
<Grid >
<ComponentTypeChip component={component} />
</Grid>
{interactionChips(component, interactions, components)}
</Grid>
);
};
const selectInteraction = (newInteraction: Interaction) => {
setSelectedInteraction(newInteraction);
};
// This triggers table rerender when the show errors variable is flipped
useEffect(() => {
if (tableRef && tableRef.current) {
tableRef.current.onChangePage(undefined, 0);
}
}, [showErrors, tableRef]);
const load = () => {
componentAPI.listUnitMeasurements(
deviceID,
componentID,
startDate,
endDate,
pageSize,
page * pageSize,
order,
undefined,
getContextKeys(),
getContextTypes(),
showErrors,
showErrors,
as
)
.then(response => {
let total: number = response.data.total ? response.data.total : 0;
let rows = UnitMeasurement.convertMeasurements(
response.data.measurements.map(um => UnitMeasurement.any(um, user))
);
// let maxPage = total / pageSize;
// if (page > maxPage) {
// tableRef.current.isOutsidePageNumbers();
// }
setRows(rows)
setTotal(total)
// resolve({
// data: rows,
// page: page,
// totalCount: total
// });
})
.catch((err: any) => {
// resolve({
// data: [],
// page: 0,
// totalCount: 0
// });
});
}
useEffect(() => {
load()
}, [page, pageSize, as])
const columns = (): Column<convertedUnitMeasurement>[] => {
return [
{
title: "Timestamp",
cellStyle: {width: "50%"},
render: row => {
return (
<Tooltip title={moment(row.timestamp).calendar()}>
<Box>
<Typography>{moment(row.timestamp).fromNow()}</Typography>
</Box>
</Tooltip>
)
}
},
{
title: "Measurement",
cellStyle: {width: "50%"},
render: row => {
return (
<UnitMeasurementSummary
component={component}
reading={row}
tableCell={true}
/>
)
}
}
]
}
// const unitMeasurementsTable = () => {
// interface Row {
// timestamp: string;
// measurementsFor: MeasurementsFor[];
// }
// return (
// <Grid item xs={12} className={classes.verticalSpacing}>
// <MaterialTable
// key="unitMeasurements"
// tableRef={tableRef}
// // icons={getTableIcons()}
// columns={[
// {
// title: "Timestamp",
// type: "datetime",
// defaultSort: "desc",
// render: (row: Row) => {
// return (
// <Tooltip title={moment(row.timestamp).calendar()}>
// <span>{moment(row.timestamp).fromNow()}</span>
// </Tooltip>
// );
// }
// },
// {
// title: "Measurement",
// sorting: false,
// field: "measurement",
// render: (row: Row) => (
// <UnitMeasurementSummary
// component={component}
// reading={row}
// tableCell={true}
// excludedNodes={component.settings.excludedNodes}
// />
// )
// }
// ]}
// options={{
// paging: true,
// pageSize: pageSize,
// pageSizeOptions: [5, 10, 20],
// padding: "default",
// showTitle: true,
// toolbar: true,
// sorting: !live.current,
// search: false,
// headerStyle: { position: "inherit", borderBottom: "1px solid rgba(81, 81, 81, 1)" }
// }}
// localization={{
// body: { emptyDataSourceMessage: "No measurements found" }
// }}
// data={query =>
// new Promise(resolve => {
// let pageSize = query.pageSize;
// let page = query.page;
// let orderBy =
// query.orderBy && query.orderBy.field ? query.orderBy.field.toString() : "timestamp";
// componentAPI
// .listUnitMeasurements(
// deviceID,
// componentID,
// startDate,
// endDate,
// pageSize,
// page * pageSize,
// orderBy,
// undefined,
// getContextKeys(),
// getContextTypes(),
// showErrors
// )
// .then(response => {
// let total: number = response.data.total ? response.data.total : 0;
// let rows = UnitMeasurement.convertMeasurements(
// response.data.measurements.map(um => UnitMeasurement.any(um, user))
// );
// let maxPage = total / pageSize;
// if (page > maxPage) {
// tableRef.current.isOutsidePageNumbers();
// }
// resolve({
// data: rows,
// page: page,
// totalCount: total
// });
// })
// .catch((err: any) => {
// resolve({
// data: [],
// page: 0,
// totalCount: 0
// });
// });
// })
// }
// />
// </Grid>
// );
// };
const handleRowsPerPageChange = (event: any) => {
const newRowsPerPage = parseInt(event.target.value, 10); // Get selected value
setPageSize(newRowsPerPage);
setPage(0)
}
const measurementsTable = () => {
return (
<Grid size={{ xs: 12 }} className={classes.verticalSpacing}>
<ResponsiveTable
rows={rows}
columns={columns()}
total={total}
page={page}
pageSize={pageSize}
setPage={setPage}
handleRowsPerPageChange={handleRowsPerPageChange}
loadMore={() => {
let currentRows = cloneDeep(rows)
let newOffset = rows.length
componentAPI.listUnitMeasurements(
deviceID,
componentID,
startDate,
endDate,
pageSize,
newOffset,
order,
undefined,
getContextKeys(),
getContextTypes(),
showErrors,
showErrors,
as)
.then(resp => {
let newRows = UnitMeasurement.convertMeasurements(
resp.data.measurements.map(um => UnitMeasurement.any(um, user))
)
setRows(currentRows.concat(newRows))
}).catch(err => {
})
}}
/>
</Grid>
);
};
return (
<PageContainer>
<Grid
container
direction="row"
justifyContent="flex-start"
alignContent="center"
rowSpacing={1}
className={classes.toolbar}>
<Grid
size={{ xs: 8, sm: 9 }}
container
justifyContent="flex-start"
alignContent="center"
className={classes.toolbarTitle}>
<SmartBreadcrumb
deviceName={device.name()}
componentName={component.name()}
groupName={group ? group.name() : undefined}
loading={loadingInitial}
reload={loadState}
/>
</Grid>
<Grid size={{ xs: 4, sm: 3 }} container justifyContent="flex-end" alignContent="center">
<ComponentActions
device={device}
components={components}
component={component}
permissions={permissions}
availablePositions={availablePositions}
availableOffsets={availableOffsets}
refreshCallback={loadState}
initialStartDate={startDate}
initialEndDate={endDate}
deviceComponentPreferences={deviceComponentPrefs}
/>
</Grid>
<Grid size={{ xs: 12 }}>
{status()}
</Grid>
</Grid>
<Grid
container
direction="column"
justifyContent="flex-start"
alignItems="center"
className={classes.contentContainer}>
<Grid container size={{ xs: 12 }}>
{
<Grid size={{ xs: 12 }} className={classes.verticalSpacing}>
<ComponentChart
deviceKey={deviceID.toString()}
componentKey={componentID}
component={component}
interactions={interactions}
sampling={sampling}
unitMeasurements={unitMeasurements}
sampleLimit={sampleLimit}
deviceComponentPreferences={deviceComponentPrefs}
updateDate={(newStartDate, newEndDate, newLive) => {
setStartDate(newStartDate);
setEndDate(newEndDate);
live.current = !!newLive;
}}
allowLive={true}
recentUnitMeasurement={
showErrors ? recentUnitMeasurement : recentGoodUnitMeasurement
}
/>
</Grid>
}
</Grid>
<Grid container size={{ xs: 12 }}>
{/* {newStructure ? unitMeasurementsTable() : measurementsTable()} */}
{measurementsTable()}
</Grid>
</Grid>
<InteractionSettings
device={device}
components={components.map((component: Component) => Component.clone(component))}
initialComponent={component}
initialInteraction={selectedInteraction}
mode="update"
isDialogOpen={isInteractionSettingsOpen}
closeDialogCallback={() => selectInteraction(getDefaultInteraction())}
refreshCallback={loadState}
canEdit={permissions.includes(pond.Permission.PERMISSION_WRITE)}
/>
</PageContainer>
);
}