diff --git a/src/common/DiffHistory.tsx b/src/common/DiffHistory.tsx
new file mode 100644
index 0000000..6f3df5e
--- /dev/null
+++ b/src/common/DiffHistory.tsx
@@ -0,0 +1,593 @@
+import React, { useState, useEffect } from "react";
+// import { createStyles, Theme, makeStyles, fade } from "@material-ui/core/styles";
+import { detailedDiff } from "deep-object-diff";
+import {
+ List,
+ ListItem,
+ ListItemText,
+ ListItemIcon,
+ Chip,
+ Avatar,
+ Grid2 as Grid
+} from "@mui/material";
+import { Add, ChangeHistory, Remove, Face, Sync } from "@mui/icons-material";
+import { amber, red, blue, green } from "@mui/material/colors";
+// import MaterialTable from "material-table";
+import moment from "moment";
+// import { getTableIcons } from "common/ResponsiveTable";
+import { pond } from "protobuf-ts/pond";
+import { useUserAPI } from "hooks";
+import { useMobile } from "hooks";
+import ObjectDescriber from "objects/ObjectDescriber";
+import { makeStyles } from "@mui/styles";
+import { alpha, Theme } from "@mui/material";
+import ResponsiveTable, { Column } from "./ResponsiveTable";
+
+const addedColour = alpha(green[600], 0.25);
+const deletedColour = alpha(red[600], 0.25);
+const updatedColour = alpha(blue[600], 0.25);
+const resyncedColour = alpha(amber[600], 0.25);
+const useStyles = makeStyles((theme: Theme) => {
+ return ({
+ added: {
+ backgroundColor: addedColour
+ },
+ deleted: {
+ backgroundColor: deletedColour
+ },
+ updated: {
+ backgroundColor: updatedColour
+ },
+ resynced: {
+ backgroundColor: resyncedColour
+ },
+ chipContainer: {
+ maxWidth: "100%",
+ maxHeight: "100%",
+ flexWrap: "wrap"
+ },
+ translateContainer: {
+ borderLeft: "2px solid white",
+ borderRight: "2px solid white",
+ borderRadius: "6px",
+ padding: "0 8px 0 8px",
+ whiteSpace: "pre",
+ margin: "8px"
+ },
+ translateBox: {
+ display: "flex",
+ flexDirection: "row",
+ alignItems: "center",
+ padding: "0px"
+ }
+ })
+});
+
+interface Diff {
+ added: any;
+ deleted: any;
+ updated: any;
+}
+
+interface RowData {
+ timestamp: string;
+ user: string;
+ changes: number;
+ before: object;
+ after: object;
+ diff: Diff;
+ status: string;
+ type?: pond.ObjectType;
+}
+
+interface SummaryProps {
+ changes: number;
+ diff: Diff;
+ before: any;
+ after: any;
+ kind: string;
+ translateKey: (key: keyof any, type?: pond.ObjectType) => string;
+ translateValue: (key: keyof any, obj: any, type?: pond.ObjectType) => string;
+ variant?: "expanded" | "inline";
+ type?: pond.ObjectType;
+}
+
+function Summary(props: SummaryProps) {
+ const classes = useStyles();
+ const { changes, diff, before, after, translateKey, translateValue, variant, type } = props;
+ //console.log(diff)
+ const { added, updated, deleted } = diff;
+
+ const translate = (key: keyof any, changeType: "added" | "deleted" | "updated"): any => {
+ switch (changeType) {
+ case "updated":
+ return (
+
+
{translateKey(key, type) + ":"}
+
{translateValue(key, before, type)}
+
{"→"}
+
{translateValue(key, after, type)}
+
+ );
+ case "deleted":
+ return translateKey(key, type);
+ default:
+ let val = translateValue(key, after, type);
+ if (isBool(val)) {
+ return translateKey(key, type);
+ }
+ return translateKey(key, type) + ": " + val;
+ }
+ };
+
+ const isBool = (val: string) => {
+ return val.toLowerCase() === "true" || val.toLowerCase() === "false";
+ };
+
+ const icon = (changeType: "added" | "deleted" | "updated") => {
+ switch (changeType) {
+ case "added":
+ return ;
+ case "deleted":
+ return ;
+ default:
+ return ;
+ }
+ };
+
+ const inlineItem = (key: any, changeType: "added" | "deleted" | "updated") => {
+ if (key !== "key") {
+ return (
+
+ {key !== "key" && (
+ {translate(key, changeType)}}
+ />
+ )}
+
+ );
+ }
+ return null;
+ };
+
+ const isMobile = useMobile();
+
+ const listItem = (key: any, changeType: "added" | "deleted" | "updated") => {
+ return (
+
+ {icon(changeType)}
+ {isMobile ? (
+ {translate(key, changeType)}
+ ) : (
+ {translate(key, changeType)}
+ )}
+
+ );
+ };
+
+ switch (variant) {
+ case "inline":
+ if (changes <= 0) {
+ return } size="small" label="Resynced" />;
+ }
+ return (
+
+ {added &&
+ Object.keys(added).length > 0 &&
+ Object.keys(added).map(key => inlineItem(key, "added"))}
+ {deleted &&
+ Object.keys(deleted).length > 0 &&
+ Object.keys(deleted).map(key => inlineItem(key, "deleted"))}
+ {updated &&
+ Object.keys(updated).length > 0 &&
+ Object.keys(updated).map(key => inlineItem(key, "updated"))}
+
+ );
+ default:
+ if (changes <= 0) {
+ return (
+
+
+
+
+
+
+ Setting were resynced to ensure consistency with the cloud
+
+
+
+ );
+ }
+ return (
+
+ {added && Object.keys(added).length > 0 && (
+ {Object.keys(added).map(key => listItem(key, "added"))}
+ )}
+ {deleted && Object.keys(deleted).length > 0 && (
+
+ {Object.keys(deleted).map(key => listItem(key, "deleted"))}
+
+ )}
+ {updated && Object.keys(updated).length > 0 && (
+
+ {Object.keys(updated).map(key => listItem(key, "updated"))}
+
+ )}
+
+ );
+ }
+}
+
+export interface Record {
+ timestamp: string;
+ data: any;
+ user: string;
+ status: string;
+ type?: pond.ObjectType;
+}
+
+export interface ListResult {
+ records: Record[];
+ offset: number;
+ total: number;
+}
+
+interface Props {
+ name: string;
+ kind: string;
+ showTitle: boolean;
+ list: (limit: number, offset: number) => Promise;
+ translateKey: (key: keyof any, type?: pond.ObjectType) => string;
+ translateValue: (key: keyof any, obj: any, type?: pond.ObjectType) => string;
+ headerStyle?: React.CSSProperties;
+ cellStyle?: React.CSSProperties | ((data: RowData[], rowData: RowData) => React.CSSProperties);
+ noPaging?: boolean;
+ sortingEnabled?: boolean;
+ filteringEnabled?: boolean;
+ drawer?: boolean;
+}
+
+export default function DiffHistory(props: Props) {
+ const {
+ kind,
+ name,
+ headerStyle,
+ cellStyle,
+ list,
+ translateKey,
+ translateValue,
+ showTitle,
+ noPaging,
+ sortingEnabled,
+ filteringEnabled,
+ drawer
+ } = props;
+ const userAPI = useUserAPI();
+ const [pageSize, setPageSize] = useState(10);
+ const [profiles, setProfiles] = useState(new Map());
+ const isMobile = useMobile();
+ const [page, setPage] = useState(0);
+ const [data, setData] = useState([]);
+ const [total, setTotal] = useState(0);
+
+ useEffect(() => {
+ new Promise(resolve => {
+ list(pageSize, page * pageSize)
+ .then((res: ListResult) => {
+ let records = res.records;
+ let uniqueUsers = Array.from(
+ new Set(records.map((record: Record) => record.user).filter(id => !profiles.has(id)))
+ );
+ let req =
+ uniqueUsers.length > 0
+ ? userAPI.getProfiles(uniqueUsers)
+ : new Promise(resolve => resolve([]));
+ req
+ .then((res: pond.UserProfile[]) => {
+ res.forEach((profile: pond.UserProfile) => profiles.set(profile.id, profile));
+ setProfiles(profiles);
+ })
+ .catch((err: any) => console.log(err))
+ .finally(() => {
+ let data: RowData[] = [];
+ for (let i = 0; i < records.length; i++) {
+ let record = records[i];
+ let timestamp = record.timestamp;
+ let user = record.user;
+ //let typeString = record.type ? ObjectDescriber(record.type).name : kind;
+ let before = {};
+ if (i < records.length - 1) {
+ if (record.type) {
+ let found = false;
+ let k = i;
+ while (!found && k < records.length - 1) {
+ let nextRecord = records[k + 1];
+ if (record.type === nextRecord.type) {
+ if (record.type === pond.ObjectType.OBJECT_TYPE_DEVICE) {
+ if (record.data.deviceId === nextRecord.data.deviceId) {
+ before = nextRecord.data;
+ found = true;
+ }
+ } else if (
+ record.type === pond.ObjectType.OBJECT_TYPE_COMPONENT ||
+ record.type === pond.ObjectType.OBJECT_TYPE_INTERACTION
+ ) {
+ if (record.data.key === nextRecord.data.key) {
+ before = nextRecord.data;
+ found = true;
+ }
+ }
+ }
+ k++;
+ }
+ } else {
+ before = records[i + 1].data;
+ }
+ }
+ let after = record.data;
+ let diff = detailedDiff(before, after) as Diff;
+ let changes = countNumChanges(diff);
+ let type = record.type;
+ let status = record.status;
+
+ data.push({
+ timestamp,
+ user,
+ changes,
+ before,
+ after,
+ diff,
+ status,
+ type
+ });
+ }
+ setData(data);
+ setTotal(res.total);
+ resolve({ data: data, page: page, totalCount: res.total });
+ });
+ })
+ .catch((err: any) => {
+ setData([]);
+ resolve({ data: [], page: 0, totalCount: 0 });
+ });
+ });
+ }, [pageSize, page, kind, list, profiles, userAPI]);
+
+ const countNumChanges = (diff: Diff): number => {
+ const { added, updated, deleted } = diff;
+ let count = 0;
+ count = count + Object.keys(added).length;
+ count = count + Object.keys(deleted).length;
+ count = count + Object.keys(updated).length;
+ return count;
+ };
+
+ const getProfile = (id: string): pond.UserProfile => {
+ if (id === "system") {
+ return pond.UserProfile.create({ id: "system", name: "System" });
+ }
+ let profile = profiles.get(id);
+ return profile ? profile : pond.UserProfile.create();
+ };
+
+ const columns = (): Column[] => {
+ return [{
+ title: "Time",
+ render: (row: RowData) => (
+
+ {moment(row.timestamp).calendar()}
+
+ ),
+ // defaultSort: "asc",
+ // cellStyle: cellStyle,
+ // customSort: (a, b) => moment(b.timestamp).diff(moment(a.timestamp))
+ },
+ {
+ title: "Object Type",
+ render: row => {
+ if (row.type) {
+ return (ObjectDescriber(row.type).name
)
+ }
+ return
+ }
+ },
+ {
+ title: "User",
+ // cellStyle: cellStyle,
+ // field: "user",
+ render: row => {
+ const profile = getProfile(row.user);
+ const avatarURL = profile.avatar;
+ const name = profile.name ? profile.name : "User " + profile.id;
+ const avatar = avatarURL ? (
+
+ ) : (
+
+
+
+ );
+ return (
+
+
+
+ );
+ },
+ // customSort: (a, b) => a.user.localeCompare(b.user)
+ //customFilterAndSearch: (filt: string, row: RowData) => row.user.toLowerCase().includes(filt.toLowerCase())
+ },
+ {
+ title: "Status",
+ render: row => row.status
+ },
+ {
+ title: "Summary",
+ // cellStyle: cellStyle,
+ // hidden: isMobile || drawer,
+ render: row => (
+
+ ),
+ // sorting: false
+ }
+ ]
+ }
+
+ const handleRowsPerPageChange = (event: any) => {
+ const newRowsPerPage = parseInt(event.target.value, 10); // Get selected value
+ setPageSize(newRowsPerPage);
+ setPage(0); // Reset to the first page
+ }
+
+ return (
+
+ title={name + " History"}
+ rows={data}
+ columns={columns()}
+ total={0}
+ pageSize={0}
+ page={0}
+ setPage={setPage}
+ handleRowsPerPageChange={handleRowsPerPageChange}
+ />
+ )
+
+ // return (
+ // {
+ // setPage(e);
+ // }}
+ // options={{
+ // paging: noPaging ? false : true,
+ // pageSize: pageSize,
+ // pageSizeOptions: [5, 10, 20],
+ // showTitle: showTitle,
+ // toolbar: showTitle,
+ // search: false,
+ // sorting: sortingEnabled ? true : false,
+ // columnsButton: showTitle,
+ // header: true,
+ // headerStyle: headerStyle,
+ // filtering: filteringEnabled
+ // }}
+ // localization={{
+ // body: { emptyDataSourceMessage: "No records found" }
+ // }}
+ // onChangeRowsPerPage={pageSize => setPageSize(pageSize)}
+ // columns={[
+ // {
+ // title: "Time",
+ // render: (row: RowData) => (
+ //
+ // {moment(row.timestamp).calendar()}
+ //
+ // ),
+ // defaultSort: "asc",
+ // cellStyle: cellStyle,
+ // customSort: (a, b) => moment(b.timestamp).diff(moment(a.timestamp))
+ // },
+ // {
+ // title: "Object Type",
+ // render: row => {
+ // if (row.type) {
+ // return ObjectDescriber(row.type).name;
+ // }
+ // }
+ // },
+ // {
+ // title: "User",
+ // cellStyle: cellStyle,
+ // field: "user",
+ // render: row => {
+ // const profile = getProfile(row.user);
+ // const avatarURL = profile.avatar;
+ // const name = profile.name ? profile.name : "User " + profile.id;
+ // const avatar = avatarURL ? (
+ //
+ // ) : (
+ //
+ //
+ //
+ // );
+ // return (
+ //
+ //
+ //
+ // );
+ // },
+ // customSort: (a, b) => a.user.localeCompare(b.user)
+ // //customFilterAndSearch: (filt: string, row: RowData) => row.user.toLowerCase().includes(filt.toLowerCase())
+ // },
+ // {
+ // title: "Status",
+ // render: row => row.status
+ // },
+ // {
+ // title: "Summary",
+ // cellStyle: cellStyle,
+ // hidden: isMobile || drawer,
+ // render: row => (
+ //
+ // ),
+ // sorting: false
+ // }
+ // ]}
+ // icons={getTableIcons()}
+ // data={data}
+ // page={page}
+ // totalCount={total}
+ // detailPanel={row => {
+ // return (
+ //
+ // );
+ // }}
+ // onRowClick={(event, row, togglePanel) => togglePanel && togglePanel()}
+ // />
+ // );
+}
diff --git a/src/component/ComponentHistory.tsx b/src/component/ComponentHistory.tsx
index 31afbdb..8173b12 100644
--- a/src/component/ComponentHistory.tsx
+++ b/src/component/ComponentHistory.tsx
@@ -1,8 +1,7 @@
-import { Box } from "@material-ui/core";
+import { Box } from "@mui/material";
import { useComponentAPI } from "hooks";
import { Component, Device } from "models";
import { pond } from "protobuf-ts/pond";
-import React from "react";
import { or } from "utils/types";
import { TranslateKey, TranslateValue } from "pbHelpers/Component";
import DiffHistory, { ListResult, Record } from "common/DiffHistory";
diff --git a/src/device/DeviceHistory.tsx b/src/device/DeviceHistory.tsx
new file mode 100644
index 0000000..f41f91c
--- /dev/null
+++ b/src/device/DeviceHistory.tsx
@@ -0,0 +1,65 @@
+import { Box } from "@mui/material";
+import { useDeviceAPI } from "hooks";
+import { Device } from "models";
+import { pond } from "protobuf-ts/pond";
+import { or } from "utils/types";
+import { TranslateKey, TranslateValue } from "pbHelpers/Device";
+import DiffHistory, { ListResult, Record } from "common/DiffHistory";
+
+interface Props {
+ device: Device;
+}
+
+export default function DeviceHistory(props: Props) {
+ const deviceAPI = useDeviceAPI();
+
+ let list = (limit: number, offset: number): Promise => {
+ return new Promise(resolve => {
+ deviceAPI
+ .listHistory(props.device.id(), limit, offset)
+ .then((res: any) => {
+ let records: Record[] = or(res.data.history, []).map((record: any) => {
+ //console.log(record)
+ return {
+ timestamp: or(record.timestamp, ""),
+ user: or(record.user, ""),
+ data: or(record.device, {}),
+ status: or(record.progress, "Unknown")
+ } as Record;
+ });
+ resolve({
+ records: records,
+ total: or(res.data.total, 0),
+ offset: or(res.data.nextOffset, 0)
+ });
+ })
+ .catch((_err: any) => {
+ resolve({
+ records: [] as Record[],
+ total: 0,
+ offset: 0
+ });
+ });
+ });
+ };
+
+ let translateKey = (key: keyof any): string => {
+ return TranslateKey(key as keyof pond.DeviceSettings);
+ };
+
+ let translateValue = (key: keyof any, obj: any): string => {
+ return TranslateValue(key as keyof pond.DeviceSettings, pond.DeviceSettings.fromObject(obj));
+ };
+ return (
+
+
+
+ );
+}
diff --git a/src/navigation/Router.tsx b/src/navigation/Router.tsx
index cb7a5ee..27bca70 100644
--- a/src/navigation/Router.tsx
+++ b/src/navigation/Router.tsx
@@ -1,4 +1,4 @@
-import { Suspense } from "react";
+import { lazy, Suspense } from "react";
import LoadingScreen from "../app/LoadingScreen";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { Typography } from "@mui/material";
@@ -14,6 +14,8 @@ import GroupsPage from "pages/Groups";
import GroupPage from "pages/Group";
import { ErrorBoundary } from "react-error-boundary";
+const DeviceHistory = lazy(() => import("pages/DeviceHistory"));
+
interface Props {
toggleTheme: () => void;
}
@@ -70,7 +72,11 @@ export default function Router(props: Props) {
path="/:deviceID" // "/settings/basic"
element={}
/>
- }
+ />
+ }
/>
diff --git a/src/objects/ObjectAlerts.tsx b/src/objects/ObjectAlerts.tsx
new file mode 100644
index 0000000..5239c6d
--- /dev/null
+++ b/src/objects/ObjectAlerts.tsx
@@ -0,0 +1,976 @@
+import {
+ Accordion,
+ AccordionDetails,
+ AccordionSummary,
+ Box,
+ Button,
+ Card,
+ Checkbox,
+ createStyles,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ FormControl,
+ FormControlLabel,
+ FormHelperText,
+ FormLabel,
+ Grid,
+ IconButton,
+ InputAdornment,
+ List,
+ ListItem,
+ ListItemIcon,
+ ListItemText,
+ ListSubheader,
+ makeStyles,
+ MenuItem,
+ Select,
+ TextField,
+ Theme,
+ Tooltip,
+ Typography
+} from "@material-ui/core";
+import { ExpandMore } from "@material-ui/icons";
+import ResponsiveDialog from "common/ResponsiveDialog";
+import { Option } from "common/SearchSelect";
+import classNames from "classnames";
+import { Component, Interaction } from "models";
+import moment from "moment";
+import { getFriendlyName, getMeasurements } from "pbHelpers/ComponentType";
+import { Measurement, Operator } from "pbHelpers/Enums";
+import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
+import { pond } from "protobuf-ts/pond";
+import { quack } from "protobuf-ts/quack";
+import { useGlobalState, useInteractionsAPI, useSnackbar } from "providers";
+import { useNotificationAPI } from "providers/pond/notificationAPI";
+import React, { useEffect, useState } from "react";
+import RemoveIcon from "@material-ui/icons/RemoveCircle";
+import AddIcon from "@material-ui/icons/AddCircle";
+import { green, red } from "@material-ui/core/colors";
+import { capitalize, cloneDeep } from "lodash";
+import { timeOfDayDescriptor } from "pbHelpers/Interaction";
+import { TimePicker } from "@material-ui/pickers";
+import { getThemeType } from "theme";
+
+interface Props {
+ linkedComponents: Map;
+ componentDevices: Map;
+ objectType: pond.ObjectType;
+ objectKey: string;
+}
+
+interface Alert {
+ conditions: pond.InteractionCondition[];
+ components: Component[];
+}
+
+const useStyles = makeStyles((theme: Theme) => {
+ return createStyles({
+ borderedContainer: {
+ padding: theme.spacing(1),
+ marginBottom: theme.spacing(2),
+ border: "1px solid rgba(255, 255, 255, 0.12)",
+ borderRadius: "4px"
+ },
+ greenButton: {
+ color: green["600"]
+ },
+ redButton: {
+ color: red["600"]
+ },
+ noPadding: {
+ padding: 0
+ },
+ timeRange: {
+ fontSize: "0.875em",
+ marginTop: "20px"
+ },
+ dark: {
+ backgroundColor: getThemeType() === "light" ? "rgb(245, 245, 245)" : "rgb(50, 50, 50)",
+ padding: 5
+ },
+ light: {
+ backgroundColor: getThemeType() === "light" ? "rgb(235, 235, 235)" : "rgb(60, 60, 60)",
+ padding: 5
+ }
+ });
+});
+
+export default function ObjectAlerts(props: Props) {
+ const { linkedComponents, componentDevices, objectKey, objectType } = props;
+ const classes = useStyles();
+ const { openSnack } = useSnackbar();
+ const [{ as }] = useGlobalState();
+ const interactionsAPI = useInteractionsAPI();
+ const [interactions, setInteractions] = useState([]);
+ const [typeOptions, setTypeOptions] = useState