frontend/src/pages/DeviceComponent.tsx

799 lines
26 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 PendingChangesChip from "common/PendingChangesChip";
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,
useHTTP,
useInteractionsAPI,
usePendingChanges,
usePrevious,
useSnackbar,
useUserAPI
} from "hooks";
import { useWebSocket } from "hooks/useWebSocket";
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 } from "providers";
import { useTeamAPI } from "providers/pond/teamAPI";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom";
// import { useRouteMatch } from "react-router";
import { isStaleStatusUpdate } from "utils/syncStatus";
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 { token } = useHTTP();
const componentPending = usePendingChanges(
component.status.synced,
!loadingInitial && component.key() !== ""
);
const liveContextKeys = useMemo(() => getContextKeys(), []);
const liveContextTypes = useMemo(() => getContextTypes(), []);
/** Matches the REST calls: omit ?as= when first context type is "team". */
const liveAs =
as && !(liveContextTypes.length > 0 && liveContextTypes[0] === "team")
? as
: undefined;
// --- Real-time component updates ---
// Streams component changes for this device, including status changes
// when the device accepts pending component updates.
useWebSocket<{ key: string; component: Component } | null>({
path: `/v1/live/devices/${deviceID}/components`,
parse: (e) => {
try {
const raw = JSON.parse(e.data);
const comp = Component.any(raw);
if (!comp?.settings) {
console.warn("[ws] received component without settings:", raw);
return null;
}
return { key: comp.key(), component: comp };
} catch (err) {
console.warn("[ws] failed to parse component:", err);
return null;
}
},
onMessage: (data) => {
if (!data || data.key !== componentID) return;
setComponent((prev) => {
if (prev.key() !== data.key) return prev;
if (isStaleStatusUpdate(prev.status, data.component.status)) {
return prev;
}
return data.component;
});
},
keys: liveContextKeys,
types: liveContextTypes,
as: liveAs,
token,
enabled: !!deviceID && componentID !== "",
});
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}>
{(componentPending.pending || componentPending.accepted) && (
<Grid >
<PendingChangesChip
pending={componentPending.pending}
accepted={componentPending.accepted}
/>
</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>
);
}