Merge Development to Main (#36)

* Fix sign up form bug

* Add run.sh to run setup and run web app

* Output node version

* Update readme with run.sh

* Fix file upload form to handle ota_update service

* Enable file upload form

Enable error boundary to catch React errors (#7)
Fix warning for link noreferrer
Include authorization header with file upload

* Remove default localhost settings (#8)

* Remove default localhost settings
Replace with deployment settings

* Fix for upload data format

* Fix test data for last commit

* Fix json link format and remove localhost default settings (#10)

* Remove default localhost settings
Replace with deployment settings

* Fix for upload data format

* Fix test data for last commit

* Fix link data format

* Fix link json again (#12)

Use id token instead of access token

* nginx things

* Web Worker Sign Out and Use Go API (#13)

* Calculate checksum and send with file upload

* Limit file upload and display rejected file error

* Add sign in timeout

* Check auth token structure before setting
Clean up

* Use web worker timer to sign out
Remove checksum
Point to Go ota update

* Remove checksum dependency

* Use compute auth service and fix static code analyzer warnings (#15)

* Clean up formatting

* Use new compute_auth service
Implment SSO
Implement token refresh
Clean up unit tests

* Fix unit tests

* Fix auth test
Fix warnings

* Update default settings for compute_auth

* Change main UI layout and add VINs to add and upload forms (#16)

* Add new upload update package form
Add new add vehicle form
Add new side menu layout
Add new toolbar layout
Update and add unit tests

* Enable add get and add vehicles

* Integration issues with ota_update service

* Update get vehicle JSON format

* Fix related unit test
Add release notes field

* Add StatusContext to display error and status messages

* Handle api error json (#18)

* Handle api error json

* Fix get vehicles error handling
Update .env.template

* Fix signout refresh (#20)

* Merge to main (#17)

* Fix sign up form bug

* Add run.sh to run setup and run web app

* Output node version

* Update readme with run.sh

* Fix file upload form to handle ota_update service

* Enable file upload form

Enable error boundary to catch React errors (#7)
Fix warning for link noreferrer
Include authorization header with file upload

* Remove default localhost settings (#8)

* Remove default localhost settings
Replace with deployment settings

* Fix for upload data format

* Fix test data for last commit

* Fix json link format and remove localhost default settings (#10)

* Remove default localhost settings
Replace with deployment settings

* Fix for upload data format

* Fix test data for last commit

* Fix link data format

* Fix link json again (#12)

Use id token instead of access token

* nginx things

* Web Worker Sign Out and Use Go API (#13)

* Calculate checksum and send with file upload

* Limit file upload and display rejected file error

* Add sign in timeout

* Check auth token structure before setting
Clean up

* Use web worker timer to sign out
Remove checksum
Point to Go ota update

* Remove checksum dependency

* Use compute auth service and fix static code analyzer warnings (#15)

* Clean up formatting

* Use new compute_auth service
Implment SSO
Implement token refresh
Clean up unit tests

* Fix unit tests

* Fix auth test
Fix warnings

* Update default settings for compute_auth

* Change main UI layout and add VINs to add and upload forms (#16)

* Add new upload update package form
Add new add vehicle form
Add new side menu layout
Add new toolbar layout
Update and add unit tests

* Enable add get and add vehicles

* Integration issues with ota_update service

* Update get vehicle JSON format

* Fix related unit test
Add release notes field

* Add StatusContext to display error and status messages

* Handle api error json (#18)

* Handle api error json

* Fix get vehicles error handling
Update .env.template

Co-authored-by: Rafi Greenberg <rgreenberg@fiskerinc.com>

* Fix sign out and refresh

* Check for bad json

Co-authored-by: Rafi Greenberg <rgreenberg@fiskerinc.com>

* Add role checks (#21)

* Add role checks

* Remove moved Roles enum

* Add package updates, car updates, and vehicle screens (#25)

* Car table update (#27)

* Add Datadog RUM (#28)

* fix run.sh

* Add updates by car screen and modal popup (#29)

* CEC-180 Cache Control (#30)

* Set cache expire to 1 day
Add snapshot tests for new screens

* Fix table pagniation random ids for snapshot tests

* Auto reload on chunk load error

* OTA Admin Portal => Admin Portal

* CEC-179 Car download progress (#32)

* Display download progress

* Change default

* Fix

* Fix

* Update readme

* Update readme and defaults
Fix Dockerfile

* CEC-179 Car update progress build fix (#33)

* Display download progress

* Change default

* Fix

* Fix

* Update readme

* Update readme and defaults
Fix Dockerfile

* Fix build

* Undo Docker changes (#34)

Co-authored-by: Rafi Greenberg <rgreenberg@fiskerinc.com>
Co-authored-by: Roger Standridge <rstandridge@fiskerinc.com>
This commit is contained in:
John Wu
2021-04-30 15:55:27 -07:00
committed by GitHub
parent bf81903ecd
commit e51450426e
19 changed files with 2138 additions and 2005 deletions

View File

@@ -1,3 +1,3 @@
REACT_APP_AUTH_SERVICE_URL = https://dev-auth.fiskerdps.com
REACT_APP_UPLOAD_SERVICE_URL = https://gw-dev.fiskerdps.com
REACT_APP_AUTH_CALLBACK_URL = https://dev-ota-admin.fiskerdps.com/
REACT_APP_AUTH_SERVICE_URL = http://localhost/compute_auth
REACT_APP_UPLOAD_SERVICE_URL = http://localhost/ota_update
REACT_APP_AUTH_CALLBACK_URL = http://localhost:3000

View File

@@ -1,16 +1,23 @@
# Fisker Admin Portal
Front-end web application for administarting OTA services
Front-end web application for administrating services
# Setup
Run `./run.sh` from the terminal or
Running locally
1. Install Node 12
2. Run `npm install`
3. Setup environment variables listed in .env.template
4. Or copy .env.template to .env
5. Edit .env with the service urls for authentication and api services
3. Copy .env.template to .env and edit the service urls for authentication and api services
4. Run `./run.sh` from the terminal
5. Access portal at localhost:3000
Running Docker container
1. Copy .env.template to .env and edit the service urls for authentication and api services
2. Build the image `docker build -t fiskerinc/portal .`
3. Start the container `docker run -p 3000:80 fiskerinc/portal`
4. Access portal at localhost:3000
## Available Scripts

View File

@@ -59,12 +59,6 @@ describe("App", () => {
await check("/package-upload", "span.MuiButton-label", "Sign In");
});
it("Route /package-upload unauthenticated", async () => {
const container = await renderRoute("/package-upload");
expect(container.querySelector("span.MuiButton-label").innerHTML).toEqual("Sign In");
expect(container).toMatchSnapshot();
});
it("Route /vehicle-add unauthenticated", async () => {
await check("/vehicle-add", "span.MuiButton-label", "Sign In");
});
@@ -105,7 +99,7 @@ describe("App", () => {
it("Route /package-upload authenticated", async () => {
setToken(TEST_AUTH_OBJECT);
await check("/package-upload", "h1", "Upload Update Package");
await check("/package-upload", "h1", "Create Update Package");
});
it("Route /vehicle-add authenticated", async () => {
@@ -115,17 +109,17 @@ describe("App", () => {
it("Route /updates authenticated", async () => {
setToken(TEST_AUTH_OBJECT);
await check("/updates", "h1", "Updates");
await check("/updates", "h1", "Update Packages");
});
it("Route /update authenticated", async () => {
setToken(TEST_AUTH_OBJECT);
await check("/update/1", "h1", "Update Package 1");
await check("/update/1", "h1", "Edit Update Package 1");
});
it("Route /carupdate-deploy authenticated", async () => {
setToken(TEST_AUTH_OBJECT);
await check("/carupdate-deploy/1", "h1", "[1] ");
await check("/carupdate-deploy/1", "h1", "Deploy [1]");
});
it("Route /carupdate-status authenticated", async () => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { useParams } from "react-router";
import { useParams, Redirect } from "react-router";
import {
Button,
Chip,
@@ -44,6 +44,7 @@ const MainForm = () => {
const [releaseNotesLink, setReleaseNotesLink] = useState("");
const [createDate, setCreateDate] = useState("");
const [selectedVehicles, setSelectedVehicles] = useState([]);
const [redirect, setRedirect] = useState("");
const classes = useStyles();
const theme = useTheme();
const handleVehiclesChange = (event) => {
@@ -60,7 +61,7 @@ const MainForm = () => {
setMessage(
`Deployed ${packageName} ${version} to ${selectedVehicles.length} cars`
);
setSelectedVehicles([]);
setRedirect(`/carupdate-status/${packageid}`);
} catch (e) {
setMessage(e.message);
}
@@ -95,10 +96,14 @@ const MainForm = () => {
setCreateDate(tsLocalDateTimeString(data.timestamp));
}, [packages]);
if (redirect.length > 0) {
return <Redirect to={redirect} />;
}
return (
<div className={classes.paper}>
<Typography component="h1" variant="h5">
{`[${packageid}] ${packageName} ${version}`}
Deploy {`${packageName} ${version} [${packageid}]`}
</Typography>
<form className={classes.form} noValidate action="{onSubmit}">
<TextField

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from "react";
import { useParams } from "react-router";
import {
LinearProgress,
Table,
TableBody,
TableCell,
@@ -25,16 +26,17 @@ import VehicleStatus from "../../Cars/StatusModal";
const MainForm = () => {
const { packageid } = useParams();
const classes = useStyles();
const [pageSize, setPageSize] = useState(10);
const [pageSize, setPageSize] = useState(25);
const [pageIndex, setPageIndex] = useState(0);
const [viewVIN, setViewVIN] = useState(null);
const {
getCarUpdates,
carUpdates,
totalCarUpdates,
getPackages,
packages,
startMonitor,
stopMonitor,
} = useUpdatesContext();
const { setMessage } = useStatusContext();
const {
@@ -54,6 +56,7 @@ const MainForm = () => {
useEffect(() => {
try {
stopMonitor();
getCarUpdates(
{
packageid,
@@ -68,6 +71,19 @@ const MainForm = () => {
// eslint-disable-next-line
}, [pageIndex, pageSize, token]);
useEffect(() => {
try {
if (carUpdates.length === 0) return;
startMonitor(token);
} catch (e) {
setMessage(e.message);
}
return () => {
stopMonitor();
};
// eslint-disable-next-line
}, [carUpdates]);
const handleChangePageIndex = (event, newIndex) => {
setPageIndex(newIndex);
};
@@ -113,7 +129,15 @@ const MainForm = () => {
{row.vin}
</span>
</TableCell>
<TableCell align="center">{row.status}</TableCell>
<TableCell align="center">
{row.status}
{row.progress > 0 && (
<LinearProgress
variant="determinate"
value={row.progress}
/>
)}
</TableCell>
<TableCell align="center">
{LocalDateTimeString(row.created)}
</TableCell>

View File

@@ -23,7 +23,7 @@ import { LocalDateTimeString } from "../../../utils/dates";
const MainForm = () => {
const classes = useStyles();
const [pageSize, setPageSize] = useState(5);
const [pageSize, setPageSize] = useState(25);
const [pageIndex, setPageIndex] = useState(0);
const { getVehicles, vehicles, totalVehicles } = useVehicleContext();
const { setMessage } = useStatusContext();

View File

@@ -8,7 +8,6 @@ export const FileUploadProvider = ({ children }) => {
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState(null);
const [cancelUpload, setCancelUpload] = useState(null);
const [linkURL, setLinkURL] = useState(null);
const [files, setFiles] = useState(null);
const done = () => {
@@ -45,6 +44,14 @@ export const FileUploadProvider = ({ children }) => {
if (!accessToken || accessToken.length === 0) {
throw new Error("Access token required");
}
if (!formData.description || formData.description.length === 0) {
throw new Error("Package update description required");
}
if (!formData.releasenotes || formData.releasenotes.length === 0) {
throw new Error("Package update release notes link required");
}
};
const upload = async (formData, accessToken, uploadFiles) => {
@@ -55,7 +62,6 @@ export const FileUploadProvider = ({ children }) => {
const filename = file.name;
setUploading(true);
setLinkURL(null);
setProgress(0);
setStatus(`Uploading ${filename}`);
setCancelUpload(getCancelToken());
@@ -70,11 +76,11 @@ export const FileUploadProvider = ({ children }) => {
if (data.message) {
throw new Error(`${data.error}. ${data.message}`);
}
const url = data && data.link ? data.link : "No URL available";
setLinkURL(url);
setStatus(`Uploaded ${filename}`);
setCancelUpload(null);
setProgress(100);
return data;
} catch (e) {
setUploading(true);
setStatus(`Error occured: ${e.message}`);
@@ -88,7 +94,6 @@ export const FileUploadProvider = ({ children }) => {
uploading,
progress,
status,
linkURL,
files,
upload,
cancel,

View File

@@ -7,6 +7,7 @@ import {
fireEvent,
waitFor,
} from "@testing-library/react";
import { useState } from "react";
import { setUploadFileDelay } from "../../services/uploadFile";
import {
@@ -30,21 +31,26 @@ describe("FileUploadContext", () => {
progress,
uploading,
status,
linkURL,
upload,
cancel,
} = useFileUploadContext();
const { message, setMessage } = useStatusContext();
const [link, setLink] = useState(null);
const TEST_FILE = [{ name: "test.jpg", size: 0 }];
const TEST_ACCESSTOKEN = "ACCESSTOKEN";
const TEST_FORMDATA = {
packagename: "TEST",
version: "VERSION",
vehicles: ["VIN"],
description: "TEST DESC",
releasenotes: "http://releasenotes.com",
};
const exec = async (form, token, file) => {
try {
await upload(form, token, file);
const data = await upload(form, token, file);
if (data.link) {
setLink(data.link);
}
} catch (e) {
setMessage(e.message);
}
@@ -56,7 +62,7 @@ describe("FileUploadContext", () => {
<div data-testid="progress">{progress.toString()}</div>
<div data-testid="status">{status}</div>
<div data-testid="message">{message}</div>
<div data-testid="linkURL">{linkURL}</div>
<div data-testid="linkURL">{link}</div>
<button
data-testid="uploadNoFile"
onClick={() => {

View File

@@ -10,6 +10,8 @@ export const UpdatesProvider = ({ children }) => {
const [carUpdates, setCarUpdates] = useState([]);
const [totalPackages, setTotalPackages] = useState(0);
const [totalCarUpdates, setTotalCarUpdates] = useState(0);
const [delayCount, setDelayCount] = useState(0);
let progressTimer = 0;
const getPackages = async (search, token) => {
let result;
@@ -96,6 +98,86 @@ export const UpdatesProvider = ({ children }) => {
return result;
};
const applyProgressStatus = (item, status) => {
if (status.msg === "DONE") {
delete item.progress;
item.status = "downloaded";
} else if (status.msg === "downloading" && status.total > 0) {
let progress = Math.floor((100 * status.bytes) / status.total);
if (progress > 99) progress = 0;
item.progress = progress;
item.status = `downloading ${progress}%`;
} else if (status.error > 0) {
item.status = "download error";
} else {
item.status = "downloading";
}
};
const applyProgressStatuses = (statuses) => {
let items = JSON.parse(JSON.stringify(carUpdates));
statuses.forEach((status) => {
let item = items.find((item) => status.id === item.id);
if (!item || status.id === 0) return;
applyProgressStatus(item, status);
});
setCarUpdates(items);
};
const updateStatusProgress = async (token) => {
stopMonitor();
if (!token || carUpdates.length === 0) return;
try {
setBusy(true);
const carupdateids = carUpdates.reduce((accum, update) => {
if (update.status !== "downloaded") accum.push(update.id);
return accum;
}, []);
if (carupdateids.length === 0) return;
const result = await api.getCarUpdateProgress(
carupdateids.join(","),
token
);
if (result.error)
throw new Error(`Get update progress error. ${result.message}`);
applyProgressStatuses(result.statuses);
} catch (e) {
} finally {
setBusy(false);
}
};
const getDelay = () => {
if (delayCount < 3) {
setDelayCount(delayCount + 1);
return 1000;
}
for (let i = 0, len = carUpdates.length; i < len; i++) {
if (carUpdates[i].status.indexOf("downloading") > -1) return 1000;
}
return 10000;
};
const startMonitor = async (token) => {
const delay = getDelay();
stopMonitor();
progressTimer = setTimeout(() => {
updateStatusProgress(token);
}, delay);
};
const stopMonitor = async () => {
if (progressTimer === 0) return;
clearTimeout(progressTimer);
progressTimer = 0;
};
return (
<UpdatesContext.Provider
value={{
@@ -109,6 +191,8 @@ export const UpdatesProvider = ({ children }) => {
createCarUpdates,
getCarUpdates,
getVINUpdates,
startMonitor,
stopMonitor,
}}
>
{children}

View File

@@ -23,4 +23,6 @@ export const useUpdatesContext = () => ({
createCarUpdates: jest.fn((data) => data),
getCarUpdates: jest.fn(() => carUpdates),
getVINUpdates: jest.fn(() => carUpdates),
startMonitor: jest.fn(),
stopMonitor: jest.fn(),
});

View File

@@ -11,12 +11,12 @@ const menuData = [
roles: [],
},
{
label: "View Updates",
label: "View Packages",
to: "/updates",
roles: [Roles.CREATE, Roles.READ],
},
{
label: "Create Updates",
label: "Create Packages",
to: "/package-upload",
roles: [Roles.CREATE],
},

View File

@@ -44,7 +44,7 @@ exports[`SideMenu Authenticated 1`] = `
<span
class="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
View Updates
View Packages
</span>
</div>
<span
@@ -66,7 +66,7 @@ exports[`SideMenu Authenticated 1`] = `
<span
class="MuiTypography-root MuiListItemText-primary MuiTypography-body1 MuiTypography-displayBlock"
>
Create Updates
Create Packages
</span>
</div>
<span

View File

@@ -11,7 +11,7 @@ exports[`File Upload Form Should render 1`] = `
<h1
class="MuiTypography-root MuiTypography-h5"
>
Upload Update Package
Create Update Package
</h1>
<form
action="{onSubmit}"
@@ -114,12 +114,19 @@ exports[`File Upload Form Should render 1`] = `
class="MuiFormControl-root MuiTextField-root MuiFormControl-marginNormal MuiFormControl-fullWidth"
>
<label
class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-outlined"
class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-outlined Mui-required Mui-required"
data-shrink="false"
for="description"
id="description-label"
>
Description
<span
aria-hidden="true"
class="MuiFormLabel-asterisk MuiInputLabel-asterisk"
>
*
</span>
</label>
<div
class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-fullWidth MuiInputBase-formControl MuiInputBase-multiline MuiOutlinedInput-multiline"
@@ -131,6 +138,7 @@ exports[`File Upload Form Should render 1`] = `
maxlength="5120"
name="description"
placeholder="Package description"
required=""
rows="4"
/>
<fieldset
@@ -142,6 +150,7 @@ exports[`File Upload Form Should render 1`] = `
>
<span>
Description
 *
</span>
</legend>
</fieldset>
@@ -151,12 +160,19 @@ exports[`File Upload Form Should render 1`] = `
class="MuiFormControl-root MuiTextField-root MuiFormControl-marginNormal MuiFormControl-fullWidth"
>
<label
class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-outlined"
class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated MuiInputLabel-outlined Mui-required Mui-required"
data-shrink="false"
for="releasenotes"
id="releasenotes-label"
>
Release Notes URL
<span
aria-hidden="true"
class="MuiFormLabel-asterisk MuiInputLabel-asterisk"
>
*
</span>
</label>
<div
class="MuiInputBase-root MuiOutlinedInput-root MuiInputBase-fullWidth MuiInputBase-formControl"
@@ -168,6 +184,7 @@ exports[`File Upload Form Should render 1`] = `
maxlength="1024"
name="releasenotes"
placeholder="Release Notes URL"
required=""
type="text"
value=""
/>
@@ -180,6 +197,7 @@ exports[`File Upload Form Should render 1`] = `
>
<span>
Release Notes URL
 *
</span>
</legend>
</fieldset>

View File

@@ -1,4 +1,4 @@
import React, { useRef } from "react";
import React, { useRef, useState } from "react";
import { Button, TextField, Typography } from "@material-ui/core";
import { DropzoneArea } from "material-ui-dropzone";
import { useUserContext } from "../../Contexts/UserContext";
@@ -9,6 +9,7 @@ import {
} from "../../Contexts/FileUploadContext";
import ModalProgressBar from "../../ModalProgressBar";
import useStyles from "../../useStyles";
import { Redirect } from "react-router";
const FileUploadZone = ({ classes, token }) => {
const { setFiles } = useFileUploadContext();
@@ -39,9 +40,10 @@ const FileUploadZone = ({ classes, token }) => {
};
const MainForm = () => {
const { uploading, upload, files } = useFileUploadContext();
const { uploading, upload, files, cancel } = useFileUploadContext();
const { token } = useUserContext();
const { setMessage } = useStatusContext();
const [redirect, setRedirect] = useState(null);
const classes = useStyles();
const packagenameEl = useRef(null);
const versionEl = useRef(null);
@@ -59,17 +61,26 @@ const MainForm = () => {
description: descEl.current.value,
releasenotes: releasenotesEl.current.value,
};
const result = await upload(formData, authToken, files);
await upload(formData, authToken, files);
if (!result || result.error) return;
cancel();
setMessage(`Package uploaded`);
setRedirect(`/carupdate-deploy/${result.id}`);
} catch (e) {
setMessage(e.message);
}
};
if (redirect && redirect.length > 0) {
return <Redirect to={redirect} />;
}
return (
<div className={classes.paper}>
<Typography component="h1" variant="h5">
Upload Update Package
Create Update Package
</Typography>
<form className={classes.form} noValidate action="{onSubmit}">
<TextField
@@ -107,6 +118,7 @@ const MainForm = () => {
inputProps={{
maxLength: "5120",
}}
required
fullWidth
multiline
rows={4}
@@ -122,6 +134,7 @@ const MainForm = () => {
inputProps={{
maxLength: "1024",
}}
required
fullWidth
placeholder="Release Notes URL"
inputRef={releasenotesEl}

View File

@@ -87,7 +87,7 @@ const MainForm = () => {
return (
<div className={classes.paper}>
<Typography component="h1" variant="h5">
Update Package {id}
Edit Update Package {id}
</Typography>
<form className={classes.form} noValidate action="{onSubmit}">
<TextField

View File

@@ -26,7 +26,7 @@ import { Roles, hasRole } from "../../../utils/roles";
const UpdatePackagesList = () => {
const classes = useStyles();
const [pageSize, setPageSize] = useState(5);
const [pageSize, setPageSize] = useState(25);
const [pageIndex, setPageIndex] = useState(0);
const { getPackages, packages, totalPackages } = useUpdatesContext();
const {
@@ -98,7 +98,7 @@ const UpdatePackagesList = () => {
return (
<div className={classes.paper} style={{ height: 700, width: "100%" }}>
<Typography component="h1" variant="h5">
Updates
Update Packages
</Typography>
<TableContainer>
<Table>

View File

@@ -41,11 +41,15 @@ const updatesAPI = {
},
getCarUpdates: async (filter, token) => {
return { data:[] };
return { data: [] };
},
getVINUpdates: async (vin, token) => {
return { data:[] };
return { data: [] };
},
getCarUpdateProgress: async (carupdateids, token) => {
return { statuses: [] };
},
};

View File

@@ -45,6 +45,15 @@ const updatesAPI = {
})
.then(fetchRespHandler);
},
getCarUpdateProgress: async (carupdateids, token) => {
var u = `${API_ENDPOINT}/carupdatesstatuses?carupdateids=${carupdateids}`;
return fetch(u, {
method: "GET",
headers: Object.assign({ "Content-Type": "application/json" }, getAuthHeaderOptions(token)),
})
.then(fetchRespHandler);
},
};
export default updatesAPI;