Merge pull request #409 from Fisker-Inc/release/0.10.0

Release/0.10.0
This commit is contained in:
Rafi Greenberg
2023-08-04 11:03:26 -08:00
committed by GitHub
15 changed files with 1522 additions and 115 deletions

114
.github/workflows/deploy-on-demand.yml vendored Normal file
View File

@@ -0,0 +1,114 @@
name: OTA Portal Deploy - On Demand
on:
workflow_dispatch:
inputs:
environment:
description: "Environment"
required: true
type: choice
options:
- dev
- stage
- preprod
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
SLACK_CHANNEL: "#cloud-builds"
SLACK_FOOTER: ""
SLACK_USERNAME: GitHub Actions
SLACK_ICON: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"
TAG: ${{ github.sha }}
PROJECT: ota-admin-portal
REGISTRY: fiskercloud.azurecr.io
jobs:
build:
runs-on: ubuntu-latest
outputs:
build-env: ${{ steps.set-env.outputs.ENVIRONMENT }}
steps:
- name: Slack Notification
uses: rtCamp/action-slack-notify@v2
- name: Checkout
uses: actions/checkout@v3
- name: Azure Login
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Login to ACR
uses: docker/login-action@v2
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.AZURE_CLIENT_ID }}
password: ${{ secrets.AZURE_CLIENT_SECRET }}
- name: Set Env
env:
ENV: ${{ inputs.environment }}
id: set-env
run: |
case ${ENV} in
dev)
ENVIRONMENT=dev;;
stage)
ENVIRONMENT=stg;;
preprod)
ENVIRONMENT=prd;;
*)
ENVIRONMENT=dev;;
esac
echo "ENVIRONMENT=${ENVIRONMENT}" >> $GITHUB_ENV
echo "ENVIRONMENT=${ENVIRONMENT}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
build-args: ENVIRONMENT=${{ env.ENVIRONMENT }}
push: true
tags: ${{ env.REGISTRY }}/${{ env.PROJECT }}:${{ env.TAG }}-${{ env.ENVIRONMENT }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build
runs-on: [self-hosted, azure]
env:
ENVIRONMENT: ${{ needs.build.outputs.build-env }}
steps:
- name: Checkout
uses: actions/checkout@v3
- uses: rtCamp/action-slack-notify@v2
env:
MSG_MINIMAL: true
SLACK_MESSAGE: "Deploying ${{ env.PROJECT }} to ${{ inputs.environment }}... :partydeploy:"
- name: Deploy
run: |-
helm upgrade \
--kube-context $ENVIRONMENT \
--set image.registry=$REGISTRY \
--set image.name=$PROJECT \
--set image.tag=$TAG-$ENVIRONMENT \
--wait -i -f k8s/values-$ENVIRONMENT.yaml $PROJECT k8s/
- name: Notify deploy
uses: rtCamp/action-slack-notify@v2
env:
MSG_MINIMAL: true
SLACK_MESSAGE: "Successfully deployed ${{ env.PROJECT }} to ${{ inputs.environment }}! :gopher_party:"
- name: Notify if failure
if: ${{ failure() }}
uses: rtCamp/action-slack-notify@v2
env:
SLACK_COLOR: ${{ job.status }}
SLACK_MESSAGE: "Failed to deploy ${{ env.PROJECT }} to ${{ inputs.environment }}! :this-is-fine:"

View File

@@ -338,58 +338,12 @@ exports[`Render Render 1`] = `
<div <div
class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12" class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12"
> >
<div <ul
class="MuiFormControl-root MuiFormControl-fullWidth" class="MuiList-root MuiList-padding"
>
<label
class="MuiFormLabel-root MuiInputLabel-root MuiInputLabel-formControl MuiInputLabel-animated Mui-required Mui-required"
data-shrink="false"
id="select-can-signals-label"
>
Select CAN signals
<span
aria-hidden="true"
class="MuiFormLabel-asterisk MuiInputLabel-asterisk"
>
*
</span>
</label>
<div
class="MuiInputBase-root MuiInput-root MuiInput-underline MuiInputBase-fullWidth MuiInput-fullWidth MuiInputBase-formControl MuiInput-formControl"
inputvariant="outlined"
>
<div
aria-haspopup="listbox"
aria-labelledby="select-can-signals-label select-can-signals"
class="MuiSelect-root MuiSelect-select MuiSelect-selectMenu MuiInputBase-input MuiInput-input"
id="select-can-signals"
role="button"
tabindex="0"
>
<span>
</span>
</div>
<input
aria-hidden="true"
class="MuiSelect-nativeInput"
required=""
tabindex="-1"
value=""
/> />
<svg <ul
aria-hidden="true" class="makeStyles-chipList-0"
class="MuiSvgIcon-root MuiSelect-icon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M7 10l5 5 5-5z"
/> />
</svg>
</div>
</div>
</div> </div>
<div <div
class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12" class="MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12"

View File

@@ -1,5 +1,5 @@
import DateFnsUtils from '@date-io/date-fns'; import DateFnsUtils from '@date-io/date-fns';
import { Button, Checkbox, Chip, CircularProgress, FormControl, FormControlLabel, Grid, InputLabel, ListItemText, MenuItem, Select } from "@material-ui/core"; import { Button, Checkbox, CircularProgress, FormControlLabel, Grid } from "@material-ui/core";
import { KeyboardDatePicker, KeyboardTimePicker, MuiPickersUtilsProvider } from '@material-ui/pickers'; import { KeyboardDatePicker, KeyboardTimePicker, MuiPickersUtilsProvider } from '@material-ui/pickers';
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { logger } from "../../../services/monitoring"; import { logger } from "../../../services/monitoring";
@@ -7,6 +7,7 @@ import { CANSignalsExportProvider, useCANSignalsExportContext } from "../../Cont
import { useStatusContext } from "../../Contexts/StatusContext"; import { useStatusContext } from "../../Contexts/StatusContext";
import { useUserContext } from "../../Contexts/UserContext"; import { useUserContext } from "../../Contexts/UserContext";
import useStyles from "../../useStyles"; import useStyles from "../../useStyles";
import { TrieSelect } from '../../Controls/TrieSelect';
const MainForm = ({ id }) => { const MainForm = ({ id }) => {
const classes = useStyles(); const classes = useStyles();
@@ -72,27 +73,19 @@ const MainForm = ({ id }) => {
setSelectedEndDate(value); setSelectedEndDate(value);
}; };
const handleSelectedItemsChange = (event) => {
const { value } = event.target;
if (value.some(item => item === "Select All")) {
setSelectAllCanSignals(true);
if (selectedCanSignals.length === canSignals.length) {
setSelectedCanSignals([]);
} else {
setSelectedCanSignals(canSignals.map(signal => signal.signal_name));
}
} else {
setSelectAllCanSignals(false);
setSelectedCanSignals(value);
}
};
const displayTimeAsGMT = (date) => { const displayTimeAsGMT = (date) => {
return gmtTimezone return gmtTimezone
? date.toLocaleString("en-US", {timeZone: "Etc/GMT"}) ? date.toLocaleString("en-US", { timeZone: "Etc/GMT" })
: date; : date;
} }
useEffect(() => {
if (canSignals.length === selectedCanSignals.length) {
setSelectAllCanSignals(true);
} else {
setSelectAllCanSignals(false);
}
}, [canSignals, selectedCanSignals, setSelectAllCanSignals]);
return ( return (
<div className={classes.paper}> <div className={classes.paper}>
@@ -175,36 +168,12 @@ const MainForm = ({ id }) => {
</MuiPickersUtilsProvider> </MuiPickersUtilsProvider>
</Grid> </Grid>
<Grid item xs={12}> <Grid item xs={12}>
<FormControl fullWidth required> <TrieSelect
<InputLabel id="select-can-signals-label">Select CAN signals</InputLabel> label="All CAN Signals"
<Select classification="Signals"
labelId="select-can-signals-label" options={canSignals.map((signal => signal.signal_name))}
id="select-can-signals" onChange={setSelectedCanSignals}
multiple />
value={selectedCanSignals}
onChange={handleSelectedItemsChange}
fullWidth
inputvariant="outlined"
renderValue={(selected) => (
<div className={classes.chips}>
{selected.map((value) => (
<Chip key={value} label={value} className={classes.chip} />
))}
</div>
)}
>
<MenuItem value="Select All">
<Checkbox checked={selectedCanSignals.length === canSignals.length} />
<ListItemText primary="Select All" />
</MenuItem>
{canSignals.map((signal) => (
<MenuItem key={signal.signal_name} value={signal.signal_name}>
<Checkbox checked={selectedCanSignals.indexOf(signal.signal_name) > -1} />
<ListItemText primary={signal.signal_name} />
</MenuItem>
))}
</Select>
</FormControl>
</Grid> </Grid>
<Grid item xs={12}> <Grid item xs={12}>
<Button <Button

View File

@@ -112,10 +112,10 @@ export const VehicleProvider = ({ children }) => {
} }
}; };
const getLocationsVehiclePaths = async (token, vinsParam) => { const getLocationsVehiclePaths = async (token, param, vins) => {
try { try {
setBusy(true); setBusy(true);
const result = await api.getLocationsVehiclePaths(token, vinsParam); const result = await api.getLocationsVehiclePaths(token, param, vins);
if (result.error) if (result.error)
throw new Error(`Get locations vehicle paths error. ${result.message}`); throw new Error(`Get locations vehicle paths error. ${result.message}`);
return result; return result;

View File

@@ -42,7 +42,8 @@ const SendDiagnosticCommand = ({ vin, token, classes }) => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (!vin) return; if (!vin) return;
const result = await getECUs({ vin }, token) const unique = true;
const result = await getECUs({ vin, unique }, token)
sortECUs(result.data) sortECUs(result.data)
result.data.push({ ecu: "TBOX" }) result.data.push({ ecu: "TBOX" })
setCurrentECU(result.data[0].ecu) setCurrentECU(result.data[0].ecu)
@@ -70,7 +71,7 @@ const SendDiagnosticCommand = ({ vin, token, classes }) => {
const TREX_MIN_VER = "1.1.141"; const TREX_MIN_VER = "1.1.141";
const isRemoteResetSupported = () => { const isRemoteResetSupported = () => {
return !carState?.trex_version ? true : cmp(carState.trex_version, TREX_MIN_VER) >= 0; return !carState?.trex_version ? true : cmp(carState.trex_version, TREX_MIN_VER) >= 0 || carState.trex_version.includes("dev");
}; };
const clickHandler = async (_) => { const clickHandler = async (_) => {

View File

@@ -0,0 +1,183 @@
import React from "react";
import {
Box,
Checkbox,
Chip,
Collapse,
Divider,
FormControlLabel,
List,
ListItem,
ListItemIcon,
ListItemSecondaryAction,
ListItemText,
} from "@material-ui/core";
import ExpandLess from '@material-ui/icons/ExpandLess';
import ExpandMore from '@material-ui/icons/ExpandMore';
import { Trie } from "./trie";
import { TrieSelectProvider, useTrieSelect } from "./TrieSelectContext";
import useStyles from "../../useStyles";
export const TrieSelect = ({
label,
classification,
onChange = () => { },
options = [],
}) => {
return (
<TrieSelectProvider onChange={onChange}>
<TrieSelectList
label={label}
classification={classification}
options={options}
/>
</TrieSelectProvider>
);
}
const TrieSelectList = ({
label,
classification,
options,
}) => {
const { selected, remove } = useTrieSelect();
const classes = useStyles();
const trie = new Trie(options);
return (
<>
<List>
<TrieSelectLevel
node={trie.getRoot()}
classification={classification}
label={label}
/>
</List>
<ul className={classes.chipList}>
{selected.map((signal) => {
return (
<li key={signal}>
<Chip label={signal} onDelete={() => remove([signal])} />
</li>
);
})}
</ul>
</>
)
}
const TrieSelectLevel = ({
prefix = "",
node,
children,
classification,
label,
level = -1,
}) => {
const classes = useStyles();
const { selected, add, remove } = useTrieSelect();
const [open, setOpen] = React.useState(false);
const completeChildren = Object.values(node.children).filter(child => child.isComplete);
const hasCompleteChildren = completeChildren.length > 0;
const descendantCount = `${node.count} ${classification}`;
const isParentOfMultiple = completeChildren.length > 1;
const isAdoptiveParentOfMultiple = completeChildren.length <= 1 && node.count > 1;
const handleExpand = () => {
setOpen(open => !open);
};
const handleCheck = (names = [], checked) => {
if (checked) {
remove(names);
} else {
add(names)
}
}
const getWords = (node, prepend = prefix, result = new Set()) => {
if (prepend === prefix && node.isComplete) result.add(prepend);
for (const child of Object.values(node.children)) {
const word = (prepend ? prepend + "_" : "") + child.data;
if (child.isComplete) {
result.add(word);
}
if (child.children) {
getWords(child, word, result);
}
}
return Array.from(result);
}
const indent = (level) => `${12 * level}px`;
const listItems = (level) => (
<>
{children}
{Object.values(node.children).map((child) => {
const fullName = (prefix ? prefix + "_" : "") + child.data;
const isChecked = selected.includes(fullName);
return (
<TrieSelectLevel
prefix={fullName}
node={child}
key={fullName}
classification={classification}
level={level}
>
{child.isComplete && (
<ListItem className={classes.whiteBackground}>
<Box sx={{ paddingLeft: indent(level), display: "flex", alignItems: "center" }}>
<ListItemIcon>
<Checkbox checked={isChecked} onClick={() => handleCheck([fullName], isChecked)} />
</ListItemIcon>
<ListItemText primary={fullName} />
</Box>
</ListItem>
)}
</TrieSelectLevel>
);
})}
</>
);
if (isParentOfMultiple || isAdoptiveParentOfMultiple) {
const allDescendants = getWords(node, prefix);
const isSelectAll = allDescendants.every(descendant => selected.includes(descendant));
return (
<>
<ListItem onClick={handleExpand} divider className={classes.defaultBackground}>
<Box sx={{ paddingLeft: indent(level) }}>
<ListItemText primary={prefix === "" ? label : prefix} secondary={descendantCount} />
</Box>
<ListItemSecondaryAction>
<Box sx={{ display: "flex", alignItems: "center", gap: "16px" }}>
<FormControlLabel
label="Select All"
labelPlacement="start"
control={
<Checkbox
checked={isSelectAll}
onClick={() => handleCheck(allDescendants, isSelectAll)}
/>
}
/>
{open ? (<ExpandLess />) : (<ExpandMore />)}
</Box>
</ListItemSecondaryAction>
</ListItem>
<Collapse in={open}>
<List>
{listItems(level + 1)}
</List>
{hasCompleteChildren && <Divider />}
</Collapse>
</>
)
}
return listItems(level);
}

View File

@@ -0,0 +1,54 @@
import React from "react";
import { render, waitFor } from "@testing-library/react";
import userEvent from '@testing-library/user-event';
import { TrieSelect } from ".";
import addSnapshotSerializer from "../../../utils/snapshot";
const options = [
"ace",
"ace_bev_one",
"ace_bev_two",
"ace_bev_three",
"bev",
"bev_chaz_deep",
"bev_chaz_deep_one",
"bev_chaz_deep_two",
];
describe("TrieSelect", () => {
beforeAll(() => {
addSnapshotSerializer(expect);
});
it("Render", async () => {
const { container } = render(
<TrieSelect
label={"The button label"}
classification="Signal"
options={options}
/>
);
await waitFor(() => {
/* render */
});
expect(container).toMatchSnapshot();
});
it("properly passes payload to callback", async () => {
const mockCallback = jest.fn();
const { getAllByText } = render(
<TrieSelect
label={"The input label"}
classification="Signal"
options={options}
onChange={mockCallback}
/>
);
const selectAll = getAllByText("Select All")[0];
userEvent.click(selectAll);
expect(mockCallback).toHaveBeenCalledWith(options);
});
});

View File

@@ -0,0 +1,38 @@
import { createContext, useState, useContext, useEffect } from "react";
const TrieSelectContext = createContext();
export const useTrieSelect = () => {
const context = useContext(TrieSelectContext);
if (context === undefined) {
throw new Error("useTrieSelect must be used within a TrieSelectProvider");
}
return context;
}
export const TrieSelectProvider = ({ children, onChange }) => {
const [selected, setSelected] = useState(new Set());
const add = (ids) => setSelected(prev => new Set([...prev, ...ids]));
const remove = (ids) => setSelected(prev => {
for (const id of ids) {
prev.delete(id);
}
return new Set(prev);
});
useEffect(() => {
onChange(Array.from(selected));
}, [onChange, selected]);
return (
<TrieSelectContext.Provider value={{
selected: Array.from(selected),
add,
remove
}}>
{children}
</TrieSelectContext.Provider>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export * from "./TrieSelect";

View File

@@ -0,0 +1,62 @@
class Node {
#count
constructor(data, isComplete = false) {
this.data = data;
this.children = {};
this.isComplete = isComplete;
this.#count = 0;
}
get count() {
return this.#count;
}
incrementCount() {
this.#count += 1;
}
}
class Trie {
constructor(words = []) {
this.root = new Node();
this.populate(words);
}
populate(words) {
for (const word of words) {
this.add(word);
}
}
add(parts, node = this.root) {
if (typeof parts === "string") {
parts = parts.split("_");
}
node.incrementCount();
if (parts.length === 0) {
node.isComplete = true;
return;
}
const part = parts.shift();
if (node.children[part]) {
this.add(parts, node.children[part]);
} else {
const newNode = new Node(part);
node.children[part] = newNode;
this.add(parts, newNode);
}
}
getRoot() {
return this.root;
}
}
export {
Trie,
};

View File

@@ -0,0 +1,24 @@
import { Trie } from "./trie";
describe("Trie", () => {
it("adds words from instantiation", () => {
const trie = new Trie(["AAA_BBB_CCC"]);
const { children: { AAA: root } } = trie.getRoot();
expect(root.data).toBe("AAA");
expect(root.children["BBB"].data).toBe("BBB");
expect(root.children["BBB"].isComplete).toBe(false);
expect(root.children["BBB"].children["CCC"].data).toBe("CCC");
expect(root.children["BBB"].children["CCC"].isComplete).toBe(true);
});
it("adds words with add method", () => {
const trie = new Trie();
trie.add("AAA_BBB_CCC");
const { children: { AAA: root } } = trie.getRoot();
expect(root.data).toBe("AAA");
expect(root.children["BBB"].data).toBe("BBB");
expect(root.children["BBB"].children["CCC"].data).toBe("CCC");
});
});

View File

@@ -44,16 +44,9 @@ const ComponentVehiclePathsMap = (props) => {
const retrieveAndStoreLocations = (accessToken) => { const retrieveAndStoreLocations = (accessToken) => {
let vinsToShowOnMap = [...props.vinsToShowOnMapColors.keys()]; let vinsToShowOnMap = [...props.vinsToShowOnMapColors.keys()];
let vinsParam = "" let param = "lookback_hours=" + props.lookbackHours
for (let vinToShowOnMap of vinsToShowOnMap) {
vinsParam += "vins="
vinsParam += vinToShowOnMap
vinsParam += "&"
}
vinsParam += "lookback_hours="
vinsParam += props.lookbackHours
return getLocationsVehiclePaths(accessToken, vinsParam) return getLocationsVehiclePaths(accessToken, param, vinsToShowOnMap)
.then(async (result) => { .then(async (result) => {
let resultArray = Object.entries(result) let resultArray = Object.entries(result)
const points = [] const points = []

View File

@@ -274,6 +274,7 @@ const useStyles = makeStyles((theme) => ({
maxWidth: "100%", maxWidth: "100%",
}, },
whiteBackground: { backgroundColor: "White" }, whiteBackground: { backgroundColor: "White" },
defaultBackground: { backgroundColor: "#fafafa" },
progressIcon: { width: 40, height: 40 }, progressIcon: { width: 40, height: 40 },
progressSuccess: { color: "green" }, progressSuccess: { color: "green" },
progressError: { color: "red" }, progressError: { color: "red" },
@@ -313,6 +314,13 @@ const useStyles = makeStyles((theme) => ({
alignItems: "center", alignItems: "center",
gap: "12px", gap: "12px",
}, },
chipList: {
display: "flex",
gap: "4px 8px",
flexWrap: "wrap",
listStyleType: "none",
paddingLeft: 0,
},
flex: { flex: {
display: "flex", display: "flex",
}, },

View File

@@ -89,13 +89,14 @@ const vehiclesAPI = {
.then(fetchRespHandler) .then(fetchRespHandler)
.catch(errorHandler), .catch(errorHandler),
getLocationsVehiclePaths: async (token, vinsParam) => getLocationsVehiclePaths: async (token, param, vins) =>
fetch(`${API_ENDPOINT}/vehicle_paths?${vinsParam}`, { fetch(`${API_ENDPOINT}/vehicle_paths?${param}`, {
method: "GET", method: "POST",
headers: Object.assign( headers: Object.assign(
{ "Content-Type": "application/json" }, { "Content-Type": "application/json" },
getAuthHeaderOptions(token) getAuthHeaderOptions(token)
), ),
body: JSON.stringify({ vins: vins }),
}) })
.then(fetchRespHandler) .then(fetchRespHandler)
.catch(errorHandler), .catch(errorHandler),