diff --git a/package-lock.json b/package-lock.json
index a598d52..5dd6166 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,6 +27,8 @@
"date-fns": "^2.29.2",
"email-validator": "^2.0.4",
"env-cmd": "^10.1.0",
+ "file-saver": "^2.0.5",
+ "filesaver": "^0.0.13",
"jwt-decode": "^3.1.2",
"leaflet": "^1.8.0",
"material-ui-dropzone": "^3.5.0",
@@ -6941,9 +6943,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001387",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001387.tgz",
- "integrity": "sha512-fKDH0F1KOJvR+mWSOvhj8lVRr/Q/mc5u5nabU2vi1/sgvlSqEsE8dOq0Hy/BqVbDkCYQPRRHB1WRjW6PGB/7PA==",
+ "version": "1.0.30001458",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001458.tgz",
+ "integrity": "sha512-lQ1VlUUq5q9ro9X+5gOEyH7i3vm+AYVT1WDCVB69XOZ17KZRhnZ9J0Sqz7wTHQaLBJccNCHq8/Ww5LlOIZbB0w==",
"funding": [
{
"type": "opencollective",
@@ -9424,6 +9426,11 @@
"webpack": "^4.0.0 || ^5.0.0"
}
},
+ "node_modules/file-saver": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
+ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
+ },
"node_modules/file-selector": {
"version": "0.1.19",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.1.19.tgz",
@@ -9443,6 +9450,15 @@
"minimatch": "^3.0.4"
}
},
+ "node_modules/filesaver": {
+ "version": "0.0.13",
+ "resolved": "https://registry.npmjs.org/filesaver/-/filesaver-0.0.13.tgz",
+ "integrity": "sha512-ay2iShYJKmzKRPk89cgb14foqtCXcJIe5i+qdlSPAouKfBv7F2VZ0lxk9GjpcODe9p2YrXfi3Q+4CRn7ZDmleQ==",
+ "dependencies": {
+ "mkdirp": "^0.5.0",
+ "safename": "0.0.4"
+ }
+ },
"node_modules/filesize": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz",
@@ -15612,6 +15628,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safename": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/safename/-/safename-0.0.4.tgz",
+ "integrity": "sha512-+n4TsvESZKTXbHxOTSyQ0Q1JCXRb6MohgrqC2fbdALzTNQP/IhPOnCNRA4JPtagQq+6DD5ZsQ3lKMy57BYvwJA=="
+ },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -22881,9 +22902,9 @@
}
},
"caniuse-lite": {
- "version": "1.0.30001387",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001387.tgz",
- "integrity": "sha512-fKDH0F1KOJvR+mWSOvhj8lVRr/Q/mc5u5nabU2vi1/sgvlSqEsE8dOq0Hy/BqVbDkCYQPRRHB1WRjW6PGB/7PA=="
+ "version": "1.0.30001458",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001458.tgz",
+ "integrity": "sha512-lQ1VlUUq5q9ro9X+5gOEyH7i3vm+AYVT1WDCVB69XOZ17KZRhnZ9J0Sqz7wTHQaLBJccNCHq8/Ww5LlOIZbB0w=="
},
"case-sensitive-paths-webpack-plugin": {
"version": "2.4.0",
@@ -24710,6 +24731,11 @@
"schema-utils": "^3.0.0"
}
},
+ "file-saver": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
+ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
+ },
"file-selector": {
"version": "0.1.19",
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.1.19.tgz",
@@ -24726,6 +24752,15 @@
"minimatch": "^3.0.4"
}
},
+ "filesaver": {
+ "version": "0.0.13",
+ "resolved": "https://registry.npmjs.org/filesaver/-/filesaver-0.0.13.tgz",
+ "integrity": "sha512-ay2iShYJKmzKRPk89cgb14foqtCXcJIe5i+qdlSPAouKfBv7F2VZ0lxk9GjpcODe9p2YrXfi3Q+4CRn7ZDmleQ==",
+ "requires": {
+ "mkdirp": "^0.5.0",
+ "safename": "0.0.4"
+ }
+ },
"filesize": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-8.0.7.tgz",
@@ -29080,6 +29115,11 @@
"is-regex": "^1.1.4"
}
},
+ "safename": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/safename/-/safename-0.0.4.tgz",
+ "integrity": "sha512-+n4TsvESZKTXbHxOTSyQ0Q1JCXRb6MohgrqC2fbdALzTNQP/IhPOnCNRA4JPtagQq+6DD5ZsQ3lKMy57BYvwJA=="
+ },
"safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
diff --git a/package.json b/package.json
index 68a020d..f90fa4c 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,8 @@
"date-fns": "^2.29.2",
"email-validator": "^2.0.4",
"env-cmd": "^10.1.0",
+ "file-saver": "^2.0.5",
+ "filesaver": "^0.0.13",
"jwt-decode": "^3.1.2",
"leaflet": "^1.8.0",
"material-ui-dropzone": "^3.5.0",
diff --git a/src/components/App/__snapshots__/App.test.js.snap b/src/components/App/__snapshots__/App.test.js.snap
index 8878484..7ec5bb8 100644
--- a/src/components/App/__snapshots__/App.test.js.snap
+++ b/src/components/App/__snapshots__/App.test.js.snap
@@ -9851,7 +9851,7 @@ exports[`App Route /vehicle-status authenticated 1`] = `
- ECUs
+ T.Rex logs
- Remote Commands
+ ECUs
+
+ Remote Commands
+
+
+
+
+
+
+
diff --git a/src/components/CANSelfServe/SelfServe/__snapshots__/index.test.jsx.snap b/src/components/CANSelfServe/SelfServe/__snapshots__/index.test.jsx.snap
new file mode 100644
index 0000000..862780e
--- /dev/null
+++ b/src/components/CANSelfServe/SelfServe/__snapshots__/index.test.jsx.snap
@@ -0,0 +1,374 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render Render 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/src/components/CANSelfServe/SelfServe/index.jsx b/src/components/CANSelfServe/SelfServe/index.jsx
new file mode 100644
index 0000000..4dbf59d
--- /dev/null
+++ b/src/components/CANSelfServe/SelfServe/index.jsx
@@ -0,0 +1,197 @@
+import DateFnsUtils from '@date-io/date-fns';
+import { Button, Checkbox, Chip, CircularProgress, FormControl, Grid, InputLabel, ListItemText, MenuItem, Select } from "@material-ui/core";
+import { KeyboardDatePicker, KeyboardTimePicker, MuiPickersUtilsProvider } from '@material-ui/pickers';
+import React, { useEffect, useState } from "react";
+import { logger } from "../../../services/monitoring";
+import { CANSignalsExportProvider, useCANSignalsExportContext } from "../../Contexts/CANSignalsExportContext";
+import { useStatusContext } from "../../Contexts/StatusContext";
+import { useUserContext } from "../../Contexts/UserContext";
+import useStyles from "../../useStyles";
+
+const MainForm = ({ id }) => {
+ const classes = useStyles();
+ const { setMessage } = useStatusContext();
+ const { busy, canSignals, getCANSignalList, getDynamicColumnCANSignals } = useCANSignalsExportContext();
+
+ const [selectedStartDate, setSelectedStartDate] = useState(new Date(Date.now() - 24 * 60 * 60 * 1000));
+ const [selectedEndDate, setSelectedEndDate] = useState(new Date());
+ const [selectedCanSignals, setSelectedCanSignals] = useState([]);
+
+
+ const {
+ token: {
+ idToken: { jwtToken: token },
+ },
+ } = useUserContext();
+
+ const handleSubmit = async (event) => {
+ event.preventDefault();
+ let timestamp_start = Date.parse(selectedStartDate.toUTCString()) / 1000
+ let timestamp_end = Date.parse(selectedEndDate.toUTCString()) / 1000
+ try {
+ await getDynamicColumnCANSignals(id, timestamp_start, timestamp_end, selectedCanSignals, token)
+ } catch(e){
+ setMessage(e.message);
+ logger.error(e.stack)
+ }
+ };
+
+ const isSubmitDisabled = !selectedStartDate || !selectedEndDate || selectedCanSignals.length === 0;
+
+ useEffect(() => {
+ (async () => {
+ try {
+ if (!token) return;
+ await getCANSignalList(token);
+ } catch (e) {
+ setMessage(e.message);
+ logger.warn(e.stack);
+ }
+ })();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [token]);
+
+ const handleDateChange = (value, dateType) => {
+ const newDate = new Date(value);
+ const oldDate = dateType === "start" ? selectedStartDate || new Date() : selectedEndDate || new Date();
+ newDate.setHours(oldDate.getHours());
+ newDate.setMinutes(oldDate.getMinutes());
+ newDate.setSeconds(oldDate.getSeconds());
+ if (dateType === "start") {
+ setSelectedStartDate(newDate);
+ } else {
+ setSelectedEndDate(newDate);
+ }
+ };
+
+ const handleTimeFromChange = (value) => {
+ setSelectedStartDate(value);
+ };
+ const handleTimeToChange = (value) => {
+ setSelectedEndDate(value);
+ };
+
+ const handleSelectedItemsChange = (event) => {
+ setSelectedCanSignals(event.target.value);
+ };
+
+ return (
+
+
+
+
+
+
+ handleDateChange(value, "start")}
+ KeyboardButtonProps={{
+ 'aria-label': 'change date',
+ }}
+ />
+
+
+
+
+
+ handleDateChange(value, "end")}
+ KeyboardButtonProps={{
+ 'aria-label': 'change date',
+ }}
+ />
+
+
+
+
+
+
+
+
+
+ Select CAN signals
+
+
+
+
+
+
+
+
+ );
+};
+
+const CANSignalExport = (props) => (
+
+
+
+);
+
+export default CANSignalExport;
diff --git a/src/components/CANSelfServe/SelfServe/index.test.jsx b/src/components/CANSelfServe/SelfServe/index.test.jsx
new file mode 100644
index 0000000..5106c38
--- /dev/null
+++ b/src/components/CANSelfServe/SelfServe/index.test.jsx
@@ -0,0 +1,42 @@
+jest.mock("../../Contexts/StatusContext");
+jest.mock("../../Contexts/UserContext");
+jest.mock("../../../services/CANSignalAPI");
+jest.useFakeTimers();
+jest.setSystemTime(new Date(2023, 3, 1, 6, 30, 45, 100));
+
+import { render, waitFor } from "@testing-library/react";
+import { BrowserRouter } from "react-router-dom";
+import addSnapshotSerializer from "../../../utils/snapshot";
+import { TEST_AUTH_OBJECT_FISKER } from "../../../utils/testing";
+
+import { StatusProvider } from "../../Contexts/StatusContext";
+import { setToken, UserProvider } from "../../Contexts/UserContext";
+import CANSignalExport from "./index";
+
+const renderCANSignalExport = async () => {
+ const { container } = render(
+
+
+
+
+
+
+
+ );
+ await waitFor(() => {
+ /* render */
+ });
+ return container;
+};
+
+describe("Render", () => {
+ beforeAll(() => {
+ addSnapshotSerializer(expect);
+ });
+
+ it("Render", async () => {
+ setToken(TEST_AUTH_OBJECT_FISKER);
+ const container = await renderCANSignalExport();
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/src/components/CANSelfServe/SelfServeTab.jsx b/src/components/CANSelfServe/SelfServeTab.jsx
new file mode 100644
index 0000000..ce250d3
--- /dev/null
+++ b/src/components/CANSelfServe/SelfServeTab.jsx
@@ -0,0 +1,21 @@
+import { Typography } from "@material-ui/core";
+import clsx from "clsx";
+import React from "react";
+import { useParams } from "react-router";
+
+import useStyles from "../useStyles";
+import SelfServe from "./SelfServe";
+
+const SelfServeTab = () => {
+ const { vin } = useParams();
+ const classes = useStyles();
+
+ return (
+
+ Can Signals Self Serve
+
+
+ );
+};
+
+export default SelfServeTab;
diff --git a/src/components/Cars/Status/TRexLogsTab.jsx b/src/components/Cars/Status/TRexLogsTab.jsx
new file mode 100644
index 0000000..4627025
--- /dev/null
+++ b/src/components/Cars/Status/TRexLogsTab.jsx
@@ -0,0 +1,36 @@
+import { Typography } from "@material-ui/core";
+import clsx from "clsx";
+import React from "react";
+import { useParams } from "react-router";
+
+import { useUserContext } from "../../Contexts/UserContext";
+import { VehicleProvider } from "../../Contexts/VehicleContext";
+import TRexLogsTable from "../../Controls/TRexLogs";
+import useStyles from "../../useStyles";
+
+const MainForm = () => {
+ const { vin } = useParams();
+ const classes = useStyles();
+ const {
+ token: {
+ idToken: { jwtToken: token },
+ },
+ } = useUserContext();
+
+ return (
+
+
+ T.Rex Logs
+
+
+
+ );
+};
+
+const TRexLogsTab = () => (
+
+
+
+);
+
+export default TRexLogsTab;
diff --git a/src/components/Cars/Status/__snapshots__/DigitalTwinTab.test.jsx.snap b/src/components/Cars/Status/__snapshots__/DigitalTwinTab.test.jsx.snap
index 0858fd3..b3bbeb4 100644
--- a/src/components/Cars/Status/__snapshots__/DigitalTwinTab.test.jsx.snap
+++ b/src/components/Cars/Status/__snapshots__/DigitalTwinTab.test.jsx.snap
@@ -123,7 +123,7 @@ exports[`DigitalTwinTab Render 1`] = `
sunroof
:
- closed
+ closed (0)
- ECUs
+ T.Rex logs
- Remote Commands
+ ECUs
+
+ Remote Commands
+
+
+
+
+
+
+
diff --git a/src/components/Cars/Status/index.jsx b/src/components/Cars/Status/index.jsx
index 1ea797f..ffe3802 100644
--- a/src/components/Cars/Status/index.jsx
+++ b/src/components/Cars/Status/index.jsx
@@ -5,6 +5,7 @@ import { useParams } from "react-router";
import { useLocation } from "react-router-dom";
import { hasRole, Permissions } from "../../../utils/roles";
+import SelfServeTab from "../../CANSelfServe/SelfServeTab";
import { useStatusContext } from "../../Contexts/StatusContext";
import { useUserContext } from "../../Contexts/UserContext";
import TabPanel from "../../Controls/TabPanel";
@@ -17,6 +18,7 @@ import DigitalTwinTab from "./DigitalTwinTab";
import ECUsTab from "./ECUsTab";
import FleetsTab from "./FleetsTab";
import RemoteCommandsTab from "./RemoteCommandsTab";
+import TRexLogsTab from "./TRexLogsTab";
const tabHashes = ["details", "updates", "filters"];
@@ -42,6 +44,10 @@ const TabViews = [
label: "CAN Signals",
component: CANSignalsTab,
},
+ {
+ label: "T.Rex logs",
+ component: TRexLogsTab,
+ },
{
label: "ECUs",
component: ECUsTab,
@@ -55,6 +61,10 @@ const TabViews = [
component: FleetsTab,
rolesPerProvider: Permissions.FiskerRead,
},
+ {
+ label: "CAN Signal Export",
+ component: SelfServeTab
+ }
];
const filterTabs = (data, groups, providers) => {
diff --git a/src/components/Cars/Status/index.test.jsx b/src/components/Cars/Status/index.test.jsx
index 21a32d6..739a284 100644
--- a/src/components/Cars/Status/index.test.jsx
+++ b/src/components/Cars/Status/index.test.jsx
@@ -6,15 +6,15 @@ jest.mock("@material-ui/core/utils/unstable_useId", () =>
);
import { render, waitFor } from "@testing-library/react";
-import { BrowserRouter } from "react-router-dom";
import routeData from "react-router";
+import { BrowserRouter } from "react-router-dom";
+import addSnapshotSerializer from "../../../utils/snapshot";
+import { TEST_AUTH_OBJECT_FISKER } from "../../../utils/testing";
import { CANFiltersProvider } from "../../Contexts/CANFiltersContext";
import { StatusProvider } from "../../Contexts/StatusContext";
-import { UserProvider, setToken } from "../../Contexts/UserContext";
-import { TEST_AUTH_OBJECT_FISKER } from "../../../utils/testing";
+import { setToken, UserProvider } from "../../Contexts/UserContext";
import CarStatus from "./index";
-import addSnapshotSerializer from "../../../utils/snapshot";
const renderCarStatus = async () => {
const { container } = render(
diff --git a/src/components/Contexts/CANFiltersContext.jsx b/src/components/Contexts/CANFiltersContext.jsx
index b751827..4b2870d 100644
--- a/src/components/Contexts/CANFiltersContext.jsx
+++ b/src/components/Contexts/CANFiltersContext.jsx
@@ -1,6 +1,6 @@
import React, { useContext, useState } from "react";
import api from "../../services/CANFiltersAPI";
-import { validateCANID, validateFilter } from "../../utils/validationSupplier";
+import { validateCANID, validateFilter, validateVIN } from "../../utils/validationSupplier";
const CANFiltersContext = React.createContext();
@@ -100,10 +100,4 @@ export const CANFiltersProvider = ({ children }) => {
);
};
-const validateVIN = (vin) => {
- if (vin == null || vin.length !== 17) {
- throw new Error("Invalid VIN");
- }
-};
-
export const useCANFiltersContext = () => useContext(CANFiltersContext);
diff --git a/src/components/Contexts/CANSignalsExportContext.jsx b/src/components/Contexts/CANSignalsExportContext.jsx
new file mode 100644
index 0000000..175c851
--- /dev/null
+++ b/src/components/Contexts/CANSignalsExportContext.jsx
@@ -0,0 +1,73 @@
+import React, { useContext, useState } from "react";
+import api from "../../services/CANSignalAPI";
+import { validateVIN } from "../../utils/validationSupplier";
+
+const CANSignalsExportContext = React.createContext();
+
+export const CANSignalsExportProvider = ({ children }) => {
+ const [busy, setBusy] = useState(false);
+
+ const [canSignals, setCanSignals] = useState([]);
+
+ const getCANSignalList = async (token) => {
+ try {
+ setBusy(true);
+ const result = await api.getCanSignalList(token);
+ if (result.error) {
+ throw new Error(`Get can signal list error. ${result.message}`);
+ }
+ setCanSignals(result.data ?? []);
+ return result;
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const getDynamicColumnCANSignals = async (vin, timestart, timeend, cansingals, token) => {
+ try {
+ setBusy(true)
+ if (!vin) return;
+
+ validateVIN(vin);
+ if (timestart > timeend) throw new Error("Start time cannot be after end time");
+ const result = await api.getCanSignalsVin(vin, timestart, timeend, cansingals, token);
+ if (result.error || !result.ok)
+ throw new Error(`Get CAN signals error. ${result.message}`);
+
+ const blob = await result.blob();
+ const reader = new FileReader();
+ reader.onload = () => {
+ const csvData = reader.result;
+ const blob = new Blob([csvData], { type: 'text/csv;charset=utf-8' });
+ const link = document.createElement('a');
+ link.href = window.URL.createObjectURL(blob);
+ link.download = 'CAN_signals.csv';
+ link.click();
+ };
+ reader.readAsText(blob);
+ } catch (e) {
+ throw new Error(e)
+ }
+ finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useCANSignalsExportContext = () => useContext(CANSignalsExportContext);
+
+
+
diff --git a/src/components/Contexts/VehicleContext.jsx b/src/components/Contexts/VehicleContext.jsx
index 412c64e..31d0eb6 100644
--- a/src/components/Contexts/VehicleContext.jsx
+++ b/src/components/Contexts/VehicleContext.jsx
@@ -1,6 +1,8 @@
import React, { useContext, useState } from "react";
+
import { logger } from "../../services/monitoring";
import api from "../../services/vehiclesAPI";
+import { validateVIN } from "../../utils/validationSupplier";
const VehicleContext = React.createContext();
@@ -285,10 +287,4 @@ const validateVehicle = (v) => {
validateVIN(v.vin);
};
-const validateVIN = (vin) => {
- if (vin == null || vin.length !== 17) {
- throw new Error("Invalid VIN");
- }
-};
-
export const useVehicleContext = () => useContext(VehicleContext);
diff --git a/src/components/Contexts/__mocks__/CANSignalsExportContext.jsx b/src/components/Contexts/__mocks__/CANSignalsExportContext.jsx
new file mode 100644
index 0000000..a69a596
--- /dev/null
+++ b/src/components/Contexts/__mocks__/CANSignalsExportContext.jsx
@@ -0,0 +1,23 @@
+let busy = false;
+let canSignals = [
+ {
+ signal_name: "123",
+ },
+ {
+ signal_name: "456",
+ },
+ {
+ signal_name: "789",
+ },
+];
+
+export const CANSignalsExportProvider = ({ children }) => {
+ return {children}
;
+};
+
+export const useCANSignalsExportContext= () => ({
+ busy,
+ canSignals,
+ getCANSignalList: jest.fn(),
+ getDynamicColumnCANSignals: jest.fn(),
+});
diff --git a/src/components/Controls/TRexLogs/index.jsx b/src/components/Controls/TRexLogs/index.jsx
new file mode 100644
index 0000000..165c4ed
--- /dev/null
+++ b/src/components/Controls/TRexLogs/index.jsx
@@ -0,0 +1,329 @@
+import React, { useEffect, useState } from "react";
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableFooter,
+ TablePagination,
+ TableRow
+} from "@material-ui/core";
+import Checkbox from '@mui/material/Checkbox';
+import FormControl from '@mui/material/FormControl';
+import FormControlLabel from '@mui/material/FormControlLabel';
+import FormGroup from '@mui/material/FormGroup';
+import FormLabel from '@mui/material/FormLabel';
+import LinearProgress from '@mui/material/LinearProgress';
+
+import {
+ KeyboardDatePicker, MuiPickersUtilsProvider
+} from '@material-ui/pickers';
+import clsx from "clsx";
+import api from "../../../services/vehiclesAPI";
+
+import DateFnsUtils from '@date-io/date-fns';
+import { TableHead } from "@mui/material";
+import { logger } from "../../../services/monitoring";
+import { useStatusContext } from "../../Contexts/StatusContext";
+
+const tableColumns = [
+ {
+ id: "level",
+ label: "Level",
+ width: "5%",
+ },
+ {
+ id: "trex_timestamp",
+ label: "T.Rex Timestamp",
+ width: "10%",
+ },
+ {
+ id: "cloud_timestamp",
+ label: "Cloud Timestamp",
+ width: "10%",
+ },
+ {
+ id: "line_number",
+ label: "Line Number",
+ width: "5%",
+ },
+ {
+ id: "filename",
+ label: "Filename",
+ width: "10%",
+ },
+ {
+ id: "msg",
+ label: "Message",
+ width: "60%",
+ },
+];
+
+const logLevelCheckBoxes = [
+ {
+ "id": "trace",
+ "label": "Trace"
+ },
+ {
+ "id": "debug",
+ "label": "Debug"
+ },
+ {
+ "id": "info",
+ "label": "Info"
+ },
+ {
+ "id": "warning",
+ "label": "Warning"
+ },
+ {
+ "id": "error",
+ "label": "Error"
+ },
+ {
+ "id": "critical",
+ "label": "Critical"
+ },
+]
+
+const transformLogs = (logs) =>
+ logs.map((log) => {
+ const { level, timestamp, received_timestamp, line_number, filename, msg } = log;
+ //const trex_time = new Date(timestamp)
+ //const cloud_time = new Date(received_timestamp)
+ return {
+ level: level,
+ trex_timestamp: timestamp,
+ cloud_timestamp: received_timestamp,
+ line_number: line_number,
+ filename: filename,
+ msg: msg
+ };
+ })
+ .flat()
+
+const fromatDateForRequest = (date) => {
+ const getYear = date.toLocaleString("default", { year: "numeric" });
+ const getMonth = date.toLocaleString("default", { month: "2-digit" });
+ const getDay = date.toLocaleString("default", { day: "2-digit" });
+ return getYear + "-" + getMonth + "-" + getDay
+};
+
+//read at least 50000 bytes per one API request
+const ONE_READ_SIZE = 50000
+const DEFAULT_PAGE_SIZE = 25
+
+const TRexLogsTable = ({ vin, token, classes }) => {
+ const [allLogsFetched, setAllLogsFetched] = useState(false)
+ const [blobSize, setBlobSize] = useState(0)
+ const [currentOffset, setCurrentOffset] = useState(0)
+ const [currectLogLevels, setCurrentLogLevels] = useState({
+ trace: true,
+ debug: true,
+ info: true,
+ warning: true,
+ error: true,
+ critical: true
+ })
+ const [logs, setLogs] = useState([]);
+ const [pageIndex, setPageIndex] = useState(0);
+ const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
+ const [selectedDate, setSelectedDate] = useState(new Date());
+ const [total, setTotal] = useState(0);
+ const { setMessage } = useStatusContext();
+
+ let controller = new AbortController()
+ const readBlob = async (offset, count) => {
+ return await api.getTRexLogs(vin, fromatDateForRequest(selectedDate), offset, count, "UP", token, controller)
+ }
+ const getDesiredSize = () => {
+ return pageSize * pageIndex + pageSize
+ }
+ const getReadPercentage = () => {
+ return (currentOffset * 100 / blobSize).toFixed(2);
+ }
+ const getFilteredLogs = (logs) => {
+ return logs.filter(log => currectLogLevels[log.level] === true)
+ }
+ const fetchAllLogs = async () => {
+ let fetched = []
+ let offset = currentOffset
+ let readSize = ONE_READ_SIZE
+ for (; ;) {
+ const result = await readBlob(offset, readSize)
+ if (result.error) {
+ fetched.error = result.error
+ break
+ }
+ setBlobSize(result.blobSize)
+ readSize *= 2
+ offset = result.RealOffset + result.bytesRead
+ setCurrentOffset(offset)
+ fetched = transformLogs(result.data).concat(fetched)
+ setLogs(fetched)
+ if (offset >= result.blobSize) {
+ setMessage(`All log for ${fromatDateForRequest(selectedDate)} fetched`)
+ setAllLogsFetched(true)
+ break
+ }
+ }
+
+ if (fetched.length === 0) {
+ if (logs.length !== 0) {
+ setMessage(`No more T.Rex logs for ${fromatDateForRequest(selectedDate)}`)
+ return
+ }
+ setTotal(0)
+ const msg = `Cannot fetch logs for ${fromatDateForRequest(selectedDate)}`
+ setMessage(msg)
+ return
+ }
+ setCurrentOffset(offset)
+ return fetched
+ }
+
+ useEffect(() => {
+ (async () => {
+ try {
+ if (!vin || !token) return;
+ const desiredSize = getDesiredSize()
+ if (desiredSize < logs.length || allLogsFetched) {
+ setTotal(getFilteredLogs(logs).length)
+ return
+ }
+ let fetched = await fetchAllLogs()
+ if (!fetched || fetched.length === 0) {
+ return
+ }
+ setTotal(getFilteredLogs(fetched).length);
+ } catch (e) {
+ setMessage(e.message);
+ logger.warn(e.stack);
+ }
+ })();
+ return () => controller?.abort()
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [vin, token, pageIndex, pageSize, selectedDate, currectLogLevels]);
+
+ const handleChangePageSize = (event) => {
+ setPageSize(parseInt(event.target.value, 10));
+ setTotal(getFilteredLogs(logs).length)
+ setPageIndex(0);
+ };
+
+ const handleNewDate = (newValue) => {
+ setPageIndex(0);
+ setCurrentOffset(0)
+ setLogs([])
+ setAllLogsFetched(false)
+ setBlobSize(0)
+ setSelectedDate(newValue)
+ };
+
+ const handleNewFilter = (event) => {
+ setPageIndex(0)
+ setCurrentLogLevels({
+ ...currectLogLevels,
+ [event.target.defaultValue]: event.target.checked,
+ });
+ };
+ return (
+
+
+
+
+
+ Log levels
+
+ {logLevelCheckBoxes.map((box) => (
+ }
+ label={box.label}
+ labelPlacement="bottom"
+ onChange={handleNewFilter}
+ />
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {
+ blobSize === 0 ? `No logs for ${fromatDateForRequest(selectedDate)}` :
+ `Read ${getReadPercentage()}% of logs`
+ }
+ {
+
+ }
+
+
+
+
+
+
+
+ {tableColumns.map((column) => (
+ {column.label}
+ ))}
+
+
+
+ {getFilteredLogs(logs).slice(-getDesiredSize(), (pageIndex === 0 ? undefined : -(pageSize * pageIndex))).map((log, i) => (
+
+ {log.level}
+ {log.trex_timestamp}
+ {log.cloud_timestamp}
+ {log.line_number}
+ {log.filename}
+ {log.msg}
+
+ ))}
+
+
+
+ setPageIndex(newValue)}
+ onRowsPerPageChange={handleChangePageSize}
+ />
+
+
+
+
+ );
+};
+
+export default TRexLogsTable;
diff --git a/src/components/DigitalTwin/index.js b/src/components/DigitalTwin/index.js
index d581c9b..24c9ee6 100644
--- a/src/components/DigitalTwin/index.js
+++ b/src/components/DigitalTwin/index.js
@@ -13,6 +13,14 @@ const openCloseState = (value) => (value ? "open" : "closed");
const mapOpenCloseState = (value) =>
keyValueTemplate(value[0], openCloseState(value[1]));
+const windowState = (value) => {
+ if (value[1] === 0 || value[1] > 100) {
+ return keyValueTemplate(value[0], `closed (${value[1]})`);
+ } else {
+ return keyValueTemplate(value[0], `${value[1]}% open`);
+ }
+}
+
const DigitalTwin = (props) => {
const classes = useStyles();
const { battery, doors, location, trex_version, ip, updated, windows, misc_windows, sunroof, dbc_version, door_locks } = props;
@@ -45,12 +53,7 @@ const DigitalTwin = (props) => {
Windows
{Object.entries(windows).map((value) => {
- if (value[1] === 0) {
- return keyValueTemplate(value[0], "closed");
- } else {
- const percentOpen = Math.min(value[1], 100);
- return keyValueTemplate(value[0], `${percentOpen}% open`);
- }
+ return windowState(value);
})}
)}
@@ -58,11 +61,7 @@ const DigitalTwin = (props) => {
Misc Windows
{Object.entries(misc_windows).map((value) => {
- if (value[1] === 0 || value[1] > 100) {
- return keyValueTemplate(value[0], `closed ${value[1]}%`);
- } else {
- return keyValueTemplate(value[0], `${value[1]}% open`);
- }
+ return windowState(value);
})}
)}
@@ -70,12 +69,7 @@ const DigitalTwin = (props) => {
Sunroof
{Object.entries(sunroof).map((value) => {
- if (value[1] === 0) {
- return keyValueTemplate(value[0], "closed");
- } else {
- const percentOpen = Math.min(value[1], 100);
- return keyValueTemplate(value[0], `${percentOpen}% open`);
- }
+ return windowState(value);
})}
)}
diff --git a/src/components/SMS/Send/__snapshots__/index.test.jsx.snap b/src/components/SMS/Send/__snapshots__/index.test.jsx.snap
index d03c0ae..dbbf31c 100644
--- a/src/components/SMS/Send/__snapshots__/index.test.jsx.snap
+++ b/src/components/SMS/Send/__snapshots__/index.test.jsx.snap
@@ -1,3 +1,168 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`SMS Send Component Render 1`] = `undefined`;
+exports[`SMS Send Component Render 1`] = `
+
+`;
diff --git a/src/components/SMS/Send/index.jsx b/src/components/SMS/Send/index.jsx
index 0d4005b..0abe10a 100644
--- a/src/components/SMS/Send/index.jsx
+++ b/src/components/SMS/Send/index.jsx
@@ -1,9 +1,9 @@
import React, { useEffect, useState } from "react";
-import { useSMSContext, SMSProvider } from "../../Contexts/SMSContext";
+import { logger } from "../../../services/monitoring";
+import { SMSProvider, useSMSContext } from "../../Contexts/SMSContext";
import { useStatusContext } from "../../Contexts/StatusContext";
import { useUserContext } from "../../Contexts/UserContext";
-import { logger } from "../../../services/monitoring";
import SendForm from "./SendForm";
import ViewResult from "./ViewResult";
diff --git a/src/components/SMS/Send/index.test.jsx b/src/components/SMS/Send/index.test.jsx
index 29ce3dd..3e9b5b8 100644
--- a/src/components/SMS/Send/index.test.jsx
+++ b/src/components/SMS/Send/index.test.jsx
@@ -1,15 +1,15 @@
-import {TEST_AUTH_OBJECT_FISKER, TEST_TOKEN_FISKER} from "../../../utils/testing";
+import { TEST_AUTH_OBJECT_FISKER } from "../../../utils/testing";
jest.mock("../../Contexts/SMSContext");
jest.mock("../../Contexts/UserContext");
-import SMSSend from "./index";
-import {BrowserRouter} from "react-router-dom";
-import {UserProvider, setToken} from "../../Contexts/UserContext";
-import {StatusProvider} from "../../Contexts/StatusContext";
+import { render, waitFor } from "@testing-library/react";
+import { BrowserRouter } from "react-router-dom";
import addSnapshotSerializer from "../../../utils/snapshot";
-import {render, waitFor} from "@testing-library/react";
+import { StatusProvider } from "../../Contexts/StatusContext";
+import { setToken, UserProvider } from "../../Contexts/UserContext";
+import SMSSend from "./index";
const renderSendSMS = async () => {
@@ -38,7 +38,7 @@ describe("SMS Send Component", () => {
it("Render", async () => {
// setToken(TEST_AUTH_OBJECT_FISKER);
- const {container} = await renderSendSMS();
+ const container = await renderSendSMS();
expect(container).toMatchSnapshot();
});
});
\ No newline at end of file
diff --git a/src/components/useStyles.jsx b/src/components/useStyles.jsx
index 3b3a752..1931093 100644
--- a/src/components/useStyles.jsx
+++ b/src/components/useStyles.jsx
@@ -285,6 +285,10 @@ const useStyles = makeStyles((theme) => ({
color: "blue",
textDecoration: "underline",
},
+ tableHeader: {
+ textDecorationStyle: "solid",
+ fontWeight:500,
+ }
}));
export default useStyles;
diff --git a/src/services/CANSignalAPI.js b/src/services/CANSignalAPI.js
new file mode 100644
index 0000000..6035dab
--- /dev/null
+++ b/src/services/CANSignalAPI.js
@@ -0,0 +1,32 @@
+import {
+ addQueryParams, errorHandler, fetchRespHandler, getAuthHeaderOptions
+} from "../utils/http";
+
+const API_ENDPOINT = process.env.REACT_APP_OTA_SERVICE_URL;
+
+const canSignalAPI = {
+
+ getCanSignalsVin: async (vin, timestamp_start, timestamp_end, can_signals, token) =>
+ fetch(addQueryParams(`${API_ENDPOINT}/can_signals_export`, { vin, timestamp_start, timestamp_end, can_signals }), {
+ method: "GET",
+ headers: Object.assign(
+ { "Content-Type": "application/json" },
+ getAuthHeaderOptions(token)
+ ),
+ responseType: "blob"
+ })
+ .catch(errorHandler),
+
+
+ getCanSignalList: async (token) =>
+ fetch(addQueryParams(`${API_ENDPOINT}/can_signals_list`), {
+ method: "GET",
+ headers: Object.assign(
+ { "Content-Type": "application/json" },
+ getAuthHeaderOptions(token)
+ ),
+ })
+ .then(fetchRespHandler)
+ .catch(errorHandler),
+};
+export default canSignalAPI;
diff --git a/src/services/__mocks__/CANSignalAPI.js b/src/services/__mocks__/CANSignalAPI.js
new file mode 100644
index 0000000..e088c01
--- /dev/null
+++ b/src/services/__mocks__/CANSignalAPI.js
@@ -0,0 +1,16 @@
+const canSignalList = [
+ { signal_name: "123"},
+ { signal_name: "456"},
+ { signal_name: "789"}
+];
+
+const canSignalAPI = {
+ getCanSignalsVin: async (vin, timestamp_start, timestamp_end, can_signals, token) => {
+ return { data: "fake data" };
+ },
+ getCanSignalList: async (token) => {
+ return canSignalList;
+ }
+};
+
+export default canSignalAPI;
\ No newline at end of file
diff --git a/src/services/__mocks__/vehiclesAPI.js b/src/services/__mocks__/vehiclesAPI.js
index d18a88b..168b539 100644
--- a/src/services/__mocks__/vehiclesAPI.js
+++ b/src/services/__mocks__/vehiclesAPI.js
@@ -58,6 +58,38 @@ const signals = {data:[
},
]};
+const trexLogs = {
+ RealOffset: 0,
+ blobSize: 62072819,
+ bytesRead: 29874,
+ data: [
+ {
+ "level": "info",
+ "timestamp": "2023-Feb-15 23:44:36.934746",
+ "line_number": 948,
+ "filename": "ota_service.cpp",
+ "msg": "ota::cleanup() Deleting directory \"/fisker/ota/1534\"",
+ "received_timestamp": "2023-Feb-15 23:44:37.203102197"
+ },
+ {
+ "level": "debug",
+ "timestamp": "2023-Feb-15 23:44:36.970715",
+ "line_number": 992,
+ "filename": "ota_service.cpp",
+ "msg": "ota::end() manifest process aborted with error state",
+ "received_timestamp": "2023-Feb-15 23:44:37.203197597"
+ },
+ {
+ "level": "debug",
+ "timestamp": "2023-Feb-15 23:44:36.971745",
+ "line_number": 91,
+ "filename": "vom_impl.cpp",
+ "msg": "vom::send_ota_result_fb() Sending 0x4F9->tbox_ota_res_fb (==OTA_error)",
+ "received_timestamp": "2023-Feb-15 23:44:37.203249997"
+ }
+ ]
+}
+
const vehiclesAPI = {
addVehicle: async (vehicle) => {
data.push(vehicle);
@@ -117,6 +149,9 @@ const vehiclesAPI = {
getCANSignals: async (vin, vehicle) => {
return signals;
},
+ getTRexLogs: async (vin, date, offset, count, direction, token) => {
+ return trexLogs;
+ },
getVersionLog: async (vin) => ({
"data": [
{
diff --git a/src/services/vehiclesAPI.js b/src/services/vehiclesAPI.js
index 9d8d0db..8d38faa 100644
--- a/src/services/vehiclesAPI.js
+++ b/src/services/vehiclesAPI.js
@@ -173,6 +173,18 @@ const vehiclesAPI = {
.then(fetchRespHandler)
.catch(errorHandler),
+ getTRexLogs: async (vin, date, offset, count, direction, token, controller) =>
+ fetch(`${API_ENDPOINT}/vehicle/${vin}/trex-logs?date=${date}&offset=${offset}&count=${count}&direction=${direction}`, {
+ method: "GET",
+ headers: Object.assign(
+ { "Content-Type": "application/json" },
+ getAuthHeaderOptions(token)
+ ),
+ signal: controller.signal
+ })
+ .then(fetchRespHandler)
+ .catch(errorHandler),
+
getVersionLog: async ({vin, ...search}, token) => {
const u = addQueryParams(`${API_ENDPOINT}/vehicle/${vin}/version/logs`, search);
return fetch(u, {
diff --git a/src/utils/http.js b/src/utils/http.js
index 2d8b69a..5709cb4 100644
--- a/src/utils/http.js
+++ b/src/utils/http.js
@@ -6,7 +6,6 @@ export const addQueryParams = (url, params) => {
const u = new URL(url);
Object.keys(params).forEach((key) => u.searchParams.append(key, params[key]));
-
return u.toString();
};
diff --git a/src/utils/locations.js b/src/utils/locations.js
index a308375..f4fdaba 100644
--- a/src/utils/locations.js
+++ b/src/utils/locations.js
@@ -1,3 +1,5 @@
+const invalidLocation = 99999;
+
export const ValidateLocationData = (location) => {
if (Math.abs(location.latitude) > 90 || Math.abs(location.longitude) > 180) {
return false;
@@ -9,6 +11,7 @@ export const ValidateLocationData = (location) => {
}
export const ValidateLocationByParam = (parameter, value) => {
+ if (invalidLocation === value) return false;
switch (parameter) {
case "latitude":
return Math.abs(value) <= 90;