using co2 for the bin activity along with cables
This commit is contained in:
parent
ee8f34dcc6
commit
41a17ded94
7 changed files with 274 additions and 101 deletions
|
|
@ -58,9 +58,9 @@ interface GrainDetails {
|
|||
export default function BinCard(props: Props) {
|
||||
const { bin, valDisplay } = props;
|
||||
const [cables, setCables] = useState<GrainCable[]>();
|
||||
const [modeDetails, setModeDetails] = useState("");
|
||||
//const [modeDetails, setModeDetails] = useState("");
|
||||
const [lidarPercentage, setLidarPercentage] = useState<number | undefined>();
|
||||
const [lidarBushels, setLidarBushels] = useState<number | undefined>();
|
||||
//const [lidarBushels, setLidarBushels] = useState<number | undefined>();
|
||||
const [{ user }] = useGlobalState();
|
||||
const classes = useStyles();
|
||||
const [average, setAverage] = useState<GrainDetails>();
|
||||
|
|
@ -84,6 +84,7 @@ export default function BinCard(props: Props) {
|
|||
let coldestNode: GrainDetails | undefined;
|
||||
let hottestNode: GrainDetails | undefined;
|
||||
let now = moment();
|
||||
//loop through the cables in the bin status to prep for display
|
||||
bin.status.grainCables.forEach(cable => {
|
||||
let c: GrainCable = new GrainCable();
|
||||
//flip the cables so that for display node 1 is at the bottom
|
||||
|
|
@ -137,8 +138,9 @@ export default function BinCard(props: Props) {
|
|||
};
|
||||
}
|
||||
|
||||
let lastRead = moment(cable.lastRead);
|
||||
let elapsedMS = now.diff(lastRead);
|
||||
//check cables missed readings
|
||||
let lastCableRead = moment(cable.lastRead);
|
||||
let elapsedMS = now.diff(lastCableRead);
|
||||
|
||||
if (elapsedMS > cable.measurementInterval) {
|
||||
let missedReadings = Math.floor(elapsedMS / cable.measurementInterval);
|
||||
|
|
@ -147,6 +149,18 @@ export default function BinCard(props: Props) {
|
|||
}
|
||||
}
|
||||
});
|
||||
//loop through the CO2's to check for missed readings
|
||||
bin.status.co2.forEach(co2 => {
|
||||
let lastRead = moment(co2.lastRead);
|
||||
let elapsedMS = now.diff(lastRead);
|
||||
if (elapsedMS > co2.measurementInterval) {
|
||||
let missedReadings = Math.floor(elapsedMS / co2.measurementInterval);
|
||||
if (missedReadings > mostMissedReadings) {
|
||||
mostMissedReadings = missedReadings;
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setCables(newGrainCables);
|
||||
setMostMissed(mostMissedReadings);
|
||||
setColdNode(coldestNode);
|
||||
|
|
@ -165,28 +179,28 @@ export default function BinCard(props: Props) {
|
|||
let ratio = 1 - cm / height;
|
||||
let capacity = or(bin.settings.specs?.bushelCapacity, 0);
|
||||
let lidarEstimate = Math.round(capacity * ratio);
|
||||
setLidarBushels(lidarEstimate);
|
||||
//setLidarBushels(lidarEstimate);
|
||||
setLidarPercentage(Math.round((lidarEstimate / capacity) * 100));
|
||||
}
|
||||
}, [bin]);
|
||||
|
||||
useEffect(() => {
|
||||
let now = moment();
|
||||
let duration = moment.duration(moment(bin.status.lastModeChange).diff(now));
|
||||
let days = duration.asDays();
|
||||
days = Math.abs(days);
|
||||
duration.subtract(moment.duration(days, "days"));
|
||||
let hours = duration.hours();
|
||||
hours = Math.abs(hours);
|
||||
// useEffect(() => {
|
||||
// let now = moment();
|
||||
// let duration = moment.duration(moment(bin.status.lastModeChange).diff(now));
|
||||
// let days = duration.asDays();
|
||||
// days = Math.abs(days);
|
||||
// duration.subtract(moment.duration(days, "days"));
|
||||
// let hours = duration.hours();
|
||||
// hours = Math.abs(hours);
|
||||
|
||||
if (days > 50 && bin.settings.mode === pond.BinMode.BIN_MODE_DRYING) {
|
||||
setModeDetails("Calculating...");
|
||||
} else if (bin.settings.mode === pond.BinMode.BIN_MODE_NONE) {
|
||||
setModeDetails("No Mode");
|
||||
} else {
|
||||
setModeDetails(Math.floor(days) + "d " + hours + "h");
|
||||
}
|
||||
}, [bin]);
|
||||
// if (days > 50 && bin.settings.mode === pond.BinMode.BIN_MODE_DRYING) {
|
||||
// setModeDetails("Calculating...");
|
||||
// } else if (bin.settings.mode === pond.BinMode.BIN_MODE_NONE) {
|
||||
// setModeDetails("No Mode");
|
||||
// } else {
|
||||
// setModeDetails(Math.floor(days) + "d " + hours + "h");
|
||||
// }
|
||||
// }, [bin]);
|
||||
|
||||
const typeDisplay = () => {
|
||||
let grainType = bin.settings.inventory?.grainType ?? pond.Grain.GRAIN_NONE;
|
||||
|
|
@ -440,9 +454,10 @@ export default function BinCard(props: Props) {
|
|||
|
||||
const componentStateBanner = () => {
|
||||
const hasCables = bin.status.grainCables.length > 0;
|
||||
const hasCO2 = bin.status.co2.length > 0;
|
||||
return (
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
{hasCables ? (
|
||||
{hasCables || hasCO2 ? (
|
||||
<React.Fragment>
|
||||
<Typography style={{ fontSize: 15, fontWeight: 650 }}>
|
||||
{mostMissed < 3 ? "Active" : mostMissed <= warningThreshold ? "Missing" : "Inactive"}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { MoreVert } from "@mui/icons-material";
|
|||
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
|
||||
import { Ambient } from "models/Ambient";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { CO2 } from "models/CO2";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => {
|
||||
return ({
|
||||
|
|
@ -123,7 +124,7 @@ export default function BinComponentTypes(props: Props) {
|
|||
const [heaters, setHeaters] = useState<Controller[]>([]);
|
||||
const [fans, setFans] = useState<Controller[]>([]);
|
||||
const [lidars, setLidars] = useState<Component[]>([]);
|
||||
const [headspaceCo2s, setHeadspaceCo2s] = useState<Component[]>([]);
|
||||
const [headspaceCo2s, setHeadspaceCo2s] = useState<CO2[]>([]);
|
||||
const [tempUnit, setTempUnit] = useState<pond.TemperatureUnit>();
|
||||
const [pressureUnit, setPressureUnit] = useState<pond.PressureUnit>();
|
||||
const [selectedComponentKey, setSelectedComponentKey] = useState("");
|
||||
|
|
@ -176,7 +177,7 @@ export default function BinComponentTypes(props: Props) {
|
|||
var lidar: Component[] = [];
|
||||
var unassigned: Component[] = [];
|
||||
var ambients: Ambient[] = [];
|
||||
var headspaceCo2s: Component[] = [];
|
||||
var headspaceCo2s: CO2[] = [];
|
||||
components.forEach(comp => {
|
||||
let pref = preferences.get(comp.key());
|
||||
if (pref) {
|
||||
|
|
@ -198,7 +199,8 @@ export default function BinComponentTypes(props: Props) {
|
|||
} else if (comp.type() === quack.ComponentType.COMPONENT_TYPE_LIDAR) {
|
||||
lidar.push(comp);
|
||||
} else if (comp.type() === quack.ComponentType.COMPONENT_TYPE_CO2) {
|
||||
headspaceCo2s.push(comp);
|
||||
let coComp = CO2.create(comp)
|
||||
headspaceCo2s.push(coComp);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -716,9 +718,8 @@ export default function BinComponentTypes(props: Props) {
|
|||
return distanceCM + "cm";
|
||||
};
|
||||
|
||||
const headspaceList = (sensors: Headspace[], lidars: Component[], headspaceCo2s: Component[]) => {
|
||||
const headspaceList = (sensors: Headspace[], lidars: Component[], headspaceCo2s: CO2[]) => {
|
||||
if (sensors.length < 1 && lidars.length < 1 && headspaceCo2s.length < 1) return null;
|
||||
let headspaceComponents = lidars.concat(headspaceCo2s);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ListSubheader component="div" disableGutters>
|
||||
|
|
@ -778,7 +779,73 @@ export default function BinComponentTypes(props: Props) {
|
|||
</ListItem>
|
||||
);
|
||||
})}
|
||||
{headspaceComponents.map((component, index) => {
|
||||
{headspaceCo2s.map((co2, index) => {
|
||||
let cIcon = GetComponentIcon(
|
||||
co2.settings.type,
|
||||
co2.settings.subtype,
|
||||
theme.palette.mode
|
||||
);
|
||||
|
||||
let colour = "white"; //default colour if there are no measurements to get the type from
|
||||
|
||||
let ppmString = "--";
|
||||
if (
|
||||
co2.status.measurement[0] &&
|
||||
co2.status.measurement[0].values[0] &&
|
||||
co2.status.measurement[0].values[0].values[0]
|
||||
) {
|
||||
colour = describeMeasurement(
|
||||
co2.status.measurement[0].type,
|
||||
co2.type(),
|
||||
co2.subType()
|
||||
).colour();
|
||||
ppmString = co2.status.measurement[0].values[0].values[0] + " ppm";
|
||||
}
|
||||
return (
|
||||
<ListItem key={index}>
|
||||
<ListItemAvatar>
|
||||
<Avatar
|
||||
variant="square"
|
||||
src={cIcon}
|
||||
alt={co2.name()}
|
||||
style={{ width: theme.spacing(3), height: theme.spacing(3) }}
|
||||
/>
|
||||
</ListItemAvatar>
|
||||
<ListItemText inset={cIcon === undefined}>{co2.name()}</ListItemText>
|
||||
|
||||
<ListItemSecondaryAction>
|
||||
<Grid container direction="row" alignItems="center">
|
||||
<Grid >
|
||||
<Box className={classes.bgItem} padding={1}>
|
||||
<div style={{ display: "flex", flexDirection: "row" }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
style={{
|
||||
color: colour
|
||||
}}>
|
||||
{ppmString}
|
||||
</Typography>
|
||||
</div>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid >
|
||||
<IconButton
|
||||
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setComponentUnnassigned(false);
|
||||
setSelectedComponentType(co2.type());
|
||||
setSelectedComponentKey(co2.key());
|
||||
setAnchorEl(event.currentTarget);
|
||||
}}>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
})
|
||||
}
|
||||
{lidars.map((component, index) => {
|
||||
let cIcon = GetComponentIcon(
|
||||
component.settings.type,
|
||||
component.settings.subtype,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import React, { useEffect, useState } from "react";
|
|||
import { avg, getTemperatureUnit } from "utils";
|
||||
import { makeStyles } from "@mui/styles";
|
||||
import { Mark } from "@mui/material/Slider/useSlider.types";
|
||||
import { CO2 } from "models/CO2";
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) => {
|
||||
return ({
|
||||
|
|
@ -117,7 +118,7 @@ const useStyles = makeStyles((theme: Theme) => {
|
|||
|
||||
interface Props {
|
||||
bin: Bin;
|
||||
headspaceCO2: Component[];
|
||||
headspaceCO2: CO2[];
|
||||
cables: GrainCable[];
|
||||
}
|
||||
|
||||
|
|
|
|||
110
src/models/CO2.ts
Normal file
110
src/models/CO2.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { pond } from "protobuf-ts/pond";
|
||||
import { quack } from "protobuf-ts/pond";
|
||||
import { or } from "utils/types";
|
||||
import { clone, cloneDeep } from "lodash";
|
||||
import { Component } from "models";
|
||||
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
|
||||
import { extractGrainCable } from "pbHelpers/ComponentTypes";
|
||||
|
||||
export class CO2 {
|
||||
public settings: pond.ComponentSettings = pond.ComponentSettings.create();
|
||||
public status: pond.ComponentStatus = pond.ComponentStatus.create();
|
||||
public lastReading: string = "";
|
||||
public ppm: number = 0;
|
||||
|
||||
public static create(comp: Component): CO2 {
|
||||
let my = new CO2();
|
||||
my.settings = comp.settings;
|
||||
my.status = comp.status;
|
||||
|
||||
if (comp.status.lastGoodMeasurement.length > 0) {
|
||||
comp.status.lastGoodMeasurement.forEach(um => {
|
||||
if (um.timestamps[0]) {
|
||||
my.lastReading = um.timestamps[0];
|
||||
}
|
||||
if (um.values[0] && um.values[0].values.length > 0) {
|
||||
if (um.type === quack.MeasurementType.MEASUREMENT_TYPE_PPM) {
|
||||
my.ppm = um.values[0].values[0];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return my;
|
||||
}
|
||||
|
||||
public static createPond(comp: pond.Component): CO2 {
|
||||
let my = new CO2();
|
||||
my.settings = comp.settings ? comp.settings : pond.ComponentSettings.create();
|
||||
my.status = comp.status ? comp.status : pond.ComponentStatus.create();
|
||||
|
||||
if (comp.status && comp.status.lastGoodMeasurement.length > 0) {
|
||||
comp.status.lastGoodMeasurement.forEach(um => {
|
||||
if (um.timestamps[0]) {
|
||||
my.lastReading = um.timestamps[0];
|
||||
}
|
||||
if (um.values[0] && um.values[0].values.length > 0) {
|
||||
if (um.type === quack.MeasurementType.MEASUREMENT_TYPE_PPM) {
|
||||
my.ppm = um.values[0].values[0];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return my;
|
||||
}
|
||||
|
||||
public static any(data: any): CO2 {
|
||||
let comp = pond.Component.fromObject(cloneDeep(data));
|
||||
let my = CO2.createPond(comp);
|
||||
if (data && data.status && data.status.lastMeasurement) {
|
||||
my.status.lastMeasurement = data.status.lastMeasurement;
|
||||
}
|
||||
return my;
|
||||
}
|
||||
|
||||
public update(other: CO2) {
|
||||
this.settings = other.settings;
|
||||
this.status = other.status;
|
||||
}
|
||||
|
||||
public name(): string {
|
||||
return this.settings.name !== "" ? this.settings.name : "Component " + this.key();
|
||||
}
|
||||
|
||||
public key(): string {
|
||||
return this.settings.key;
|
||||
}
|
||||
|
||||
public location(): quack.ComponentID {
|
||||
return quack.ComponentID.fromObject({
|
||||
type: this.settings.type,
|
||||
addressType: this.settings.addressType,
|
||||
address: this.settings.address
|
||||
});
|
||||
}
|
||||
|
||||
public locationString(): string {
|
||||
return (
|
||||
or(this.settings.type, 0).toString() +
|
||||
"-" +
|
||||
or(this.settings.addressType, 0).toString() +
|
||||
"-" +
|
||||
or(this.settings.address, 0).toString()
|
||||
);
|
||||
}
|
||||
|
||||
public type(): quack.ComponentType {
|
||||
return this.settings.type;
|
||||
}
|
||||
|
||||
public subType(): number {
|
||||
return this.settings.subtype;
|
||||
}
|
||||
|
||||
//return the CO2 as a component
|
||||
public asComponent(): Component {
|
||||
let component = Component.create();
|
||||
component.settings = this.settings;
|
||||
component.status = this.status;
|
||||
return component;
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +68,7 @@ import { Controller } from "models/Controller";
|
|||
import TaskViewer from "tasks/TaskViewer";
|
||||
import ButtonGroup from "common/ButtonGroup";
|
||||
import BinTransactions from "bin/BinTransactions";
|
||||
import { CO2 } from "models/CO2";
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
|
|
@ -192,7 +193,7 @@ export default function Bin(props: Props) {
|
|||
const [ambients, setAmbients] = useState<Ambient[]>([]);
|
||||
const [grainCables, setGrainCables] = useState<GrainCable[]>([]);
|
||||
const [pressures, setPressures] = useState<Pressure[]>([]);
|
||||
const [headspaceCO2, setHeadspaceCO2] = useState<Component[]>([]);
|
||||
const [headspaceCO2, setHeadspaceCO2] = useState<CO2[]>([]);
|
||||
const [heaters, setHeaters] = useState<Controller[]>([]);
|
||||
const [fans, setFans] = useState<Controller[]>([]);
|
||||
const [detail, setDetail] = useState<
|
||||
|
|
@ -210,63 +211,13 @@ export default function Bin(props: Props) {
|
|||
new Map<string, number>()
|
||||
);
|
||||
const [binPresets, setBinPresets] = useState<DevicePreset[]>([]);
|
||||
const [missedCableReadings, setMissedCableReadings] = useState(0);
|
||||
const [missedReadings, setMissedReadings] = useState(0);
|
||||
|
||||
const handleChange = (_event: React.ChangeEvent<{}>, newValue: number) => {
|
||||
setValue(newValue);
|
||||
};
|
||||
|
||||
// const StyledToggle = styled(ToggleButton)(() => ({
|
||||
// root: {
|
||||
// backgroundColor: "transparent",
|
||||
// overflow: "visible",
|
||||
// content: "content-box",
|
||||
// "&$selected": {
|
||||
// backgroundColor: "gold",
|
||||
// color: "black",
|
||||
// borderRadius: 24,
|
||||
// fontWeight: "bold"
|
||||
// },
|
||||
// "&$selected:hover": {
|
||||
// backgroundColor: "rgb(255, 255, 0)",
|
||||
// color: "black",
|
||||
// borderRadius: 24
|
||||
// }
|
||||
// },
|
||||
// selected: {}
|
||||
// }))
|
||||
|
||||
// const StyledToggleButtonGroup = styled(ToggleButtonGroup)(({ theme }: { theme: Theme }) => ({
|
||||
// grouped: {
|
||||
// margin: theme.spacing(-0.5),
|
||||
// border: "none",
|
||||
// padding: theme.spacing(1),
|
||||
// "&:not(:first-child):not(:last-child)": {
|
||||
// borderRadius: 24,
|
||||
// marginRight: theme.spacing(0.5),
|
||||
// marginLeft: theme.spacing(0.5)
|
||||
// },
|
||||
// "&:first-child": {
|
||||
// borderRadius: 24,
|
||||
// marginLeft: theme.spacing(0.25)
|
||||
// },
|
||||
// "&:last-child": {
|
||||
// borderRadius: 24,
|
||||
// marginRight: theme.spacing(0.25)
|
||||
// }
|
||||
// },
|
||||
// root: {
|
||||
// backgroundColor: darken(
|
||||
// theme.palette.background.paper,
|
||||
// getThemeType() === "light" ? 0.05 : 0.25
|
||||
// ),
|
||||
// borderRadius: 24,
|
||||
// content: "border-box"
|
||||
// }
|
||||
// }))
|
||||
|
||||
const load = useCallback(() => {
|
||||
console.log(loadRef.current)
|
||||
if (loadRef.current || user.id() === "") return;
|
||||
setBinLoading(true);
|
||||
loadRef.current = true;
|
||||
|
|
@ -365,9 +316,10 @@ export default function Bin(props: Props) {
|
|||
var fans: Controller[] = [];
|
||||
var heaters: Controller[] = [];
|
||||
var pressures: Pressure[] = [];
|
||||
var headspaceCo2: Component[] = [];
|
||||
var headspaceCo2: CO2[] = [];
|
||||
var mostMissed: number = 0;
|
||||
let now = moment();
|
||||
|
||||
components.forEach(comp => {
|
||||
let pref = preferences.get(comp.key());
|
||||
if (pref) {
|
||||
|
|
@ -396,7 +348,17 @@ export default function Bin(props: Props) {
|
|||
pressures.push(Pressure.create(comp));
|
||||
if (pref.type === pond.BinComponent.BIN_COMPONENT_HEADSPACE) {
|
||||
if (comp.type() === quack.ComponentType.COMPONENT_TYPE_CO2) {
|
||||
headspaceCo2.push(comp);
|
||||
//check if there are missed readings from the co2 and compare them to what we already had
|
||||
let coComp = CO2.create(comp)
|
||||
let lastRead = moment(coComp.lastReading);
|
||||
let elapsedMS = now.diff(lastRead);
|
||||
if (elapsedMS > coComp.settings.measurementPeriodMs) {
|
||||
let missedReadings = Math.floor(elapsedMS / coComp.settings.measurementPeriodMs);
|
||||
if (missedReadings > mostMissed) {
|
||||
mostMissed = missedReadings;
|
||||
}
|
||||
}
|
||||
headspaceCo2.push(coComp);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -404,7 +366,7 @@ export default function Bin(props: Props) {
|
|||
}
|
||||
}
|
||||
});
|
||||
setMissedCableReadings(mostMissed);
|
||||
setMissedReadings(mostMissed);
|
||||
setPlenums(plenums);
|
||||
setGrainCables(grainCables);
|
||||
setPressures(pressures);
|
||||
|
|
@ -1098,9 +1060,37 @@ export default function Bin(props: Props) {
|
|||
};
|
||||
|
||||
const componentState = () => {
|
||||
const hasCables = grainCables.length > 0
|
||||
const hasCO2 = headspaceCO2.length > 0
|
||||
return (
|
||||
<Box display="flex" alignContent="center" alignItems="center">
|
||||
<Typography
|
||||
{hasCables || hasCO2 ?
|
||||
<React.Fragment>
|
||||
<Typography
|
||||
align="center"
|
||||
style={{
|
||||
marginRight: 10,
|
||||
fontWeight: 650,
|
||||
fontSize: 15,
|
||||
textAlign: "center",
|
||||
color: "black"
|
||||
}}>
|
||||
{missedReadings < 3
|
||||
? "Active"
|
||||
: missedReadings <= warningThreshold
|
||||
? "Missing"
|
||||
: "Inactive"}
|
||||
</Typography>
|
||||
{missedReadings < 3 ? (
|
||||
<CheckCircle style={{ color: "green" }} />
|
||||
) : missedReadings <= warningThreshold ? (
|
||||
<Warning style={{ color: "yellow" }} />
|
||||
) : (
|
||||
<Warning style={{ color: "red" }} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
:
|
||||
<Typography
|
||||
align="center"
|
||||
style={{
|
||||
marginRight: 10,
|
||||
|
|
@ -1109,19 +1099,9 @@ export default function Bin(props: Props) {
|
|||
textAlign: "center",
|
||||
color: "black"
|
||||
}}>
|
||||
{missedCableReadings < 3
|
||||
? "Active"
|
||||
: missedCableReadings <= warningThreshold
|
||||
? "Missing"
|
||||
: "Inactive"}
|
||||
</Typography>
|
||||
{missedCableReadings < 3 ? (
|
||||
<CheckCircle style={{ color: "green" }} />
|
||||
) : missedCableReadings <= warningThreshold ? (
|
||||
<Warning style={{ color: "yellow" }} />
|
||||
) : (
|
||||
<Warning style={{ color: "red" }} />
|
||||
)}
|
||||
Unmonitored
|
||||
</Typography>
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue