Merge branch 'dev_environment' of gitlab.com:brandx/bxt-app into dev_environment

This commit is contained in:
csawatzky 2026-06-19 13:44:53 -06:00
commit 0cc8a7878a
4 changed files with 314 additions and 35 deletions

View file

@ -19,9 +19,10 @@ import {
Tooltip, Tooltip,
Typography Typography
} from "@mui/material"; } from "@mui/material";
import { import {
ExpandMore, ExpandMore,
Close as CloseIcon Close as CloseIcon,
Remove as RemoveIcon
} from "@mui/icons-material"; } from "@mui/icons-material";
import PeriodSelect from "common/time/PeriodSelect"; import PeriodSelect from "common/time/PeriodSelect";
import SearchSelect, { Option } from "common/SearchSelect"; import SearchSelect, { Option } from "common/SearchSelect";
@ -97,6 +98,7 @@ interface Props {
component: Component; component: Component;
componentChanged: (component: Component, formValid: boolean) => void; componentChanged: (component: Component, formValid: boolean) => void;
canEdit: boolean; canEdit: boolean;
hideControllerFields?: boolean;
} }
interface ComponentData { interface ComponentData {
@ -119,7 +121,7 @@ export default function ComponentForm(props: Props) {
const classes = useStyles(); const classes = useStyles();
const grainOptions = GrainOptions(); const grainOptions = GrainOptions();
const [{ user }] = useGlobalState(); const [{ user }] = useGlobalState();
const { device, component, componentChanged, canEdit } = props; const { device, component, componentChanged, canEdit, hideControllerFields } = props;
const [reportExpanded, setReportExpanded] = useState(false); const [reportExpanded, setReportExpanded] = useState(false);
const [dataUsageWarningDismissed, setDataUsageWarningDismissed] = useState(true); const [dataUsageWarningDismissed, setDataUsageWarningDismissed] = useState(true);
const [form, setForm] = useState<ComponentData>({ const [form, setForm] = useState<ComponentData>({
@ -137,7 +139,8 @@ export default function ComponentForm(props: Props) {
sensorDistance: "0", sensorDistance: "0",
}); });
const [compMode, setCompMode] = useState<any>(); const [compMode, setCompMode] = useState<any>();
const [useCustomGrain, setUseCustomGrain] = useState<boolean>(false) const [useCustomGrain, setUseCustomGrain] = useState<boolean>(false);
const [controlEmail, setControlEmail] = useState("");
//const [numCalibrations, setNumCalibrations] = useState(0) //const [numCalibrations, setNumCalibrations] = useState(0)
useEffect(() => { useEffect(() => {
@ -356,6 +359,21 @@ export default function ComponentForm(props: Props) {
setForm(f); setForm(f);
}; };
const addControlEmail = () => {
const trimmed = controlEmail.trim().toLowerCase();
if (trimmed === "" || form.component.settings.controlEmails.includes(trimmed)) return;
let f = cloneDeep(form);
f.component.settings.controlEmails.push(trimmed);
setForm(f);
setControlEmail("");
};
const removeControlEmail = (index: number) => {
let f = cloneDeep(form);
f.component.settings.controlEmails.splice(index, 1);
setForm(f);
};
const toggleMeasure = (event: any) => { const toggleMeasure = (event: any) => {
let f = cloneDeep(form); let f = cloneDeep(form);
f.measure = event.target.checked; f.measure = event.target.checked;
@ -1341,7 +1359,7 @@ export default function ComponentForm(props: Props) {
}} }}
/> />
)} )}
{isController(component.settings.type) && ( {isController(component.settings.type) && !hideControllerFields && (
<React.Fragment> <React.Fragment>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
<TextField <TextField
@ -1382,6 +1400,62 @@ export default function ComponentForm(props: Props) {
/> />
</FormControl> </FormControl>
</Grid> </Grid>
<Grid size={{ xs: 12 }}>
<Typography variant="subtitle2" style={{ marginTop: 16, marginBottom: 8 }}>
Authorized Control Emails
</Typography>
<Grid container spacing={1} alignItems="center">
<Grid size={{ xs: 9 }}>
<TextField
label="Email address"
type="email"
fullWidth
variant="outlined"
size="small"
value={controlEmail}
onChange={e => setControlEmail(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault();
addControlEmail();
}
}}
disabled={!canEdit}
/>
</Grid>
<Grid size={{ xs: 3 }}>
<Button
fullWidth
variant="outlined"
color="primary"
onClick={addControlEmail}
disabled={!canEdit || controlEmail.trim() === ""}>
Add
</Button>
</Grid>
</Grid>
{component.settings.controlEmails.map((email, i) => (
<Grid
container
key={i}
alignItems="center"
justifyContent="space-between"
style={{ marginTop: 4 }}>
<Grid>
<Typography variant="body2">{email}</Typography>
</Grid>
<Grid>
<IconButton
size="small"
onClick={() => removeControlEmail(i)}
disabled={!canEdit}
className={classes.redButton}>
<RemoveIcon />
</IconButton>
</Grid>
</Grid>
))}
</Grid>
</React.Fragment> </React.Fragment>
)} )}
{showDataUsageWarning && ( {showDataUsageWarning && (

View file

@ -8,6 +8,7 @@ import {
DialogContentText, DialogContentText,
DialogTitle, DialogTitle,
Divider, Divider,
FormControl,
FormControlLabel, FormControlLabel,
Grid2 as Grid, Grid2 as Grid,
IconButton, IconButton,
@ -41,8 +42,10 @@ import {
GetComponentTypeOptions, GetComponentTypeOptions,
getFriendlyName, getFriendlyName,
getMeasurements, getMeasurements,
isController isController,
} from "pbHelpers/ComponentType"; } from "pbHelpers/ComponentType";
import PeriodSelect from "common/time/PeriodSelect";
import { bestUnit, milliToX } from "common/time/duration";
import { import {
ComponentAvailabilityMap, ComponentAvailabilityMap,
DeviceAvailabilityMap, DeviceAvailabilityMap,
@ -177,6 +180,7 @@ export default function ComponentSettings(props: Props) {
const [formValid, setFormValid] = useState(false); const [formValid, setFormValid] = useState(false);
const [excludedNodes, setExcludedNodes] = useState<number[]>([]); const [excludedNodes, setExcludedNodes] = useState<number[]>([]);
const [nodeToExclude, setNodeToExclude] = useState(0); const [nodeToExclude, setNodeToExclude] = useState(0);
const [controlEmail, setControlEmail] = useState("");
const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => { const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => {
setTabVal(newValue); setTabVal(newValue);
@ -498,6 +502,7 @@ export default function ComponentSettings(props: Props) {
component={formComponent} component={formComponent}
device={device} device={device}
canEdit={canEdit} canEdit={canEdit}
hideControllerFields={isController(formComponent.settings.type)}
componentChanged={(formComp, isValid) => { componentChanged={(formComp, isValid) => {
formComponent.settings = formComp.settings; formComponent.settings = formComp.settings;
setFormValid(isValid); setFormValid(isValid);
@ -867,6 +872,167 @@ export default function ComponentSettings(props: Props) {
); );
}; };
const addControlEmail = () => {
const trimmed = controlEmail.trim().toLowerCase();
if (trimmed === "" || formComponent.settings.controlEmails.includes(trimmed)) return;
let f = cloneDeep(formComponent);
f.settings.controlEmails.push(trimmed);
setFormComponent(f);
setControlEmail("");
};
const removeControlEmail = (index: number) => {
let f = cloneDeep(formComponent);
f.settings.controlEmails.splice(index, 1);
setFormComponent(f);
};
const handleOutputModeChanged = (event: any) => {
let f = cloneDeep(formComponent);
f.settings.defaultOutputState = event.target.value;
setFormComponent(f);
};
const handleMinCycleTimeChanged = (ms: number) => {
let f = cloneDeep(formComponent);
f.settings.minCycleTimeMs = Number(ms);
setFormComponent(f);
};
const isMinCycleTimeValid = () => {
const ext = extension(formComponent.settings.type, formComponent.settings.subtype);
if (!ext.isController) return true;
let min = Math.max(0, ext.minCycleTimeMs ? ext.minCycleTimeMs : 0);
return formComponent.settings.minCycleTimeMs >= min;
};
const minCycleTimeDescription = () => {
const ext = extension(formComponent.settings.type, formComponent.settings.subtype);
if (!ext.isController) return "";
const min = Math.max(0, ext.minCycleTimeMs ? ext.minCycleTimeMs : 0);
const unit = bestUnit(min);
const value = milliToX(min, unit).toString();
return "Must be at least " + value + " " + unit;
};
const controlContent = () => {
return (
<DialogContent>
<TextField
id="output-mode"
name="outputMode"
select
label="Output Mode"
value={formComponent.settings.defaultOutputState}
onChange={handleOutputModeChanged}
margin="normal"
fullWidth
variant="outlined"
disabled={!canEdit}>
<MenuItem value={0}>Auto</MenuItem>
<MenuItem value={1}>On</MenuItem>
<MenuItem value={2}>Off</MenuItem>
</TextField>
<FormControl fullWidth>
<PeriodSelect
id="minCycleTimeMs"
label="Minimum cycle time"
units={["seconds", "minutes"]}
isDisabled={!canEdit}
isError={!isMinCycleTimeValid()}
initialMs={
formComponent.settings.minCycleTimeMs
? formComponent.settings.minCycleTimeMs
: undefined
}
onChange={handleMinCycleTimeChanged}
helperText={
isMinCycleTimeValid()
? "The minimum amount of time that the component spends in a state"
: minCycleTimeDescription()
}
/>
</FormControl>
<Typography variant="subtitle2" sx={{ marginTop: 2, marginBottom: 1 }}>
Authorized Control Emails
</Typography>
<Grid container spacing={1} alignItems="center">
<Grid size={{ xs: 9 }}>
<TextField
label="Email address"
type="email"
fullWidth
variant="outlined"
size="small"
value={controlEmail}
onChange={e => setControlEmail(e.target.value)}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault();
addControlEmail();
}
}}
disabled={!canEdit}
/>
</Grid>
<Grid size={{ xs: 3 }}>
<Button
fullWidth
variant="outlined"
color="primary"
onClick={addControlEmail}
disabled={!canEdit || controlEmail.trim() === ""}>
Add
</Button>
</Grid>
</Grid>
{formComponent.settings.controlEmails.map((email, i) => (
<Grid
container
key={i}
alignItems="center"
justifyContent="space-between"
style={{ marginTop: 4 }}>
<Grid>
<Typography variant="body2">{email}</Typography>
</Grid>
<Grid>
<IconButton
size="small"
onClick={() => removeControlEmail(i)}
disabled={!canEdit}
className={classNames(classes.redButton)}>
<RemoveIcon />
</IconButton>
</Grid>
</Grid>
))}
<Divider sx={{ marginTop: 3, marginBottom: 2 }} />
<Typography variant="subtitle2" gutterBottom>
Email Control
</Typography>
<Typography variant="body2" color="textSecondary" gutterBottom>
Authorized emails can control this component by sending an email to the
control address. Contact your administrator for the control email
address. Use one of the following as the email subject:
</Typography>
<Box
sx={{
backgroundColor: "action.hover",
borderRadius: 1,
padding: 1.5,
marginTop: 1,
fontFamily: "monospace",
fontSize: "0.85rem"
}}>
<div>{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - ON</div>
<div>{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - OFF</div>
<div>{device.id()}:{formComponent.settings.type}-{formComponent.settings.addressType}-{formComponent.settings.address} - AUTO</div>
</Box>
</DialogContent>
);
};
const actions = () => { const actions = () => {
return ( return (
<DialogActions> <DialogActions>
@ -1042,36 +1208,55 @@ export default function ComponentSettings(props: Props) {
scroll="paper"> scroll="paper">
{title()} {title()}
<Divider /> <Divider />
{!isController(formComponent.settings.type) && ( {isController(formComponent.settings.type) ? (
<Tabs <React.Fragment>
value={tabVal} <Tabs
indicatorColor="primary" value={tabVal}
textColor="primary" indicatorColor="primary"
onChange={handleChange}> textColor="primary"
<Tab label="General" /> onChange={handleChange}>
<Tab label="Overlays" /> <Tab label="General" />
<Tab label="Nodes" disabled={!prevComponent} /> <Tab label="Control" />
</Tabs> </Tabs>
<TabPanelMine value={tabVal} index={0}>
{content()}
</TabPanelMine>
<TabPanelMine value={tabVal} index={1}>
{controlContent()}
</TabPanelMine>
</React.Fragment>
) : (
<React.Fragment>
<Tabs
value={tabVal}
indicatorColor="primary"
textColor="primary"
onChange={handleChange}>
<Tab label="General" />
<Tab label="Overlays" />
<Tab label="Nodes" disabled={!prevComponent} />
</Tabs>
<TabPanelMine value={tabVal} index={0}>
{content()}
</TabPanelMine>
<TabPanelMine value={tabVal} index={1}>
<DialogContent>
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
alignContent="center"
spacing={1}>
{overlayGroup()}
</Grid>
</DialogContent>
</TabPanelMine>
<TabPanelMine value={tabVal} index={2}>
{componentPrefs()}
</TabPanelMine>
</React.Fragment>
)} )}
<TabPanelMine value={tabVal} index={0}>
{content()}
</TabPanelMine>
<TabPanelMine value={tabVal} index={1}>
<DialogContent>
<Grid
container
direction="row"
justifyContent="space-between"
alignItems="center"
alignContent="center"
spacing={1}>
{overlayGroup()}
</Grid>
</DialogContent>
</TabPanelMine>
<TabPanelMine value={tabVal} index={2}>
{componentPrefs()}
</TabPanelMine>
{actions()} {actions()}
</ResponsiveDialog> </ResponsiveDialog>
)} )}

View file

@ -22,6 +22,14 @@ class VersionChip extends React.Component<Props, State> {
variant="outlined" variant="outlined"
label={firmwareVersionHelper.description} label={firmwareVersionHelper.description}
icon={firmwareVersionHelper.icon} icon={firmwareVersionHelper.icon}
sx={
firmwareVersionHelper.colour
? {
borderColor: firmwareVersionHelper.colour,
color: firmwareVersionHelper.colour
}
: undefined
}
/> />
</Tooltip> </Tooltip>
); );

View file

@ -9,6 +9,7 @@ export interface FirmwareVersionHelper {
const oldFirmwareColour = "var(--status-warning)"; const oldFirmwareColour = "var(--status-warning)";
const currentFirmwareColour = "var(--status-ok)"; const currentFirmwareColour = "var(--status-ok)";
const unknownFirmwareColour = "var(--status-warning)";
export function getFirmwareVersionHelper( export function getFirmwareVersionHelper(
version: string, version: string,
@ -17,24 +18,35 @@ export function getFirmwareVersionHelper(
const firmwareIcon = <FirmwareIcon />; const firmwareIcon = <FirmwareIcon />;
const oldFirmwareIcon = <FirmwareIcon style={{ color: oldFirmwareColour }} />; const oldFirmwareIcon = <FirmwareIcon style={{ color: oldFirmwareColour }} />;
const currentFirmwareIcon = <FirmwareIcon style={{ color: currentFirmwareColour }} />; const currentFirmwareIcon = <FirmwareIcon style={{ color: currentFirmwareColour }} />;
const unknownFirmwareIcon = <FirmwareIcon style={{ color: unknownFirmwareColour }} />;
let helper = {} as FirmwareVersionHelper; let helper = {} as FirmwareVersionHelper;
if (!version || version === "") { if (!version || version === "") {
helper.description = "Unknown version"; helper.description = "Unknown version";
helper.icon = oldFirmwareIcon; helper.icon = oldFirmwareIcon;
helper.tooltip = "We don't know what version your device is, it may behave unexpectedly"; helper.tooltip = "We don't know what version your device is, it may behave unexpectedly";
helper.colour = oldFirmwareColour;
} else if (version.startsWith("!")) {
const cleanVersion = version.substring(1);
helper.description = cleanVersion;
helper.icon = unknownFirmwareIcon;
helper.tooltip = "Firmware " + cleanVersion + " was reported by the device but is missing from the firmware list";
helper.colour = unknownFirmwareColour;
} else if (available !== "" && version !== available) { } else if (available !== "" && version !== available) {
helper.description = version; helper.description = version;
helper.icon = oldFirmwareIcon; helper.icon = oldFirmwareIcon;
helper.tooltip = helper.tooltip =
"A firmware upgrade is available, some features may be disabled for out-of-date devices"; "A firmware upgrade is available, some features may be disabled for out-of-date devices";
helper.colour = oldFirmwareColour;
} else if (available === "") { } else if (available === "") {
helper.description = version; helper.description = version;
helper.icon = firmwareIcon; helper.icon = firmwareIcon;
helper.tooltip = "Running version " + version; helper.tooltip = "Running version " + version;
helper.colour = "";
} else { } else {
helper.description = version; helper.description = version;
helper.icon = currentFirmwareIcon; helper.icon = currentFirmwareIcon;
helper.tooltip = "Your device is running the latest firmware"; helper.tooltip = "Your device is running the latest firmware";
helper.colour = currentFirmwareColour;
} }
return helper; return helper;
} }