added more of the status for the gate to the gates dashboard, also changed the sensor graphs to have them in a specific order
This commit is contained in:
parent
41aa84d28f
commit
f02aa3a6e0
6 changed files with 183 additions and 87 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -42,7 +42,7 @@
|
||||||
"mui-tel-input": "^7.0.0",
|
"mui-tel-input": "^7.0.0",
|
||||||
"notistack": "^3.0.1",
|
"notistack": "^3.0.1",
|
||||||
"openweathermap-ts": "^1.2.10",
|
"openweathermap-ts": "^1.2.10",
|
||||||
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#staging",
|
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#gate_dashboard",
|
||||||
"query-string": "^9.2.1",
|
"query-string": "^9.2.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
|
|
@ -10953,7 +10953,7 @@
|
||||||
},
|
},
|
||||||
"node_modules/protobuf-ts": {
|
"node_modules/protobuf-ts": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#c9ef7906fd97cda8ef4bd149ec4a796159a7c067",
|
"resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#7b1d4cc9b83dbd50ce5fd2bc751f4f8f68471861",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"protobufjs": "^6.8.8"
|
"protobufjs": "^6.8.8"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@
|
||||||
"mui-tel-input": "^7.0.0",
|
"mui-tel-input": "^7.0.0",
|
||||||
"notistack": "^3.0.1",
|
"notistack": "^3.0.1",
|
||||||
"openweathermap-ts": "^1.2.10",
|
"openweathermap-ts": "^1.2.10",
|
||||||
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#master",
|
"protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#gate_dashboard",
|
||||||
"query-string": "^9.2.1",
|
"query-string": "^9.2.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-beautiful-dnd": "^13.1.1",
|
"react-beautiful-dnd": "^13.1.1",
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ export default function GateDevice(props: Props) {
|
||||||
const [tempKey, setTempKey] = useState("");
|
const [tempKey, setTempKey] = useState("");
|
||||||
const [pressureKey, setPressureKey] = useState("");
|
const [pressureKey, setPressureKey] = useState("");
|
||||||
const [ambientKey, setAmbientKey] = useState("");
|
const [ambientKey, setAmbientKey] = useState("");
|
||||||
|
const [greenLightKey, setGreenLightKey] = useState("");
|
||||||
|
const [redLightKey, setRedLightKey] = useState("");
|
||||||
const { openSnack } = useSnackbar();
|
const { openSnack } = useSnackbar();
|
||||||
const [{ user }] = useGlobalState();
|
const [{ user }] = useGlobalState();
|
||||||
const [interactionDialog, setInteractionDialog] = useState(false);
|
const [interactionDialog, setInteractionDialog] = useState(false);
|
||||||
|
|
@ -57,8 +59,18 @@ export default function GateDevice(props: Props) {
|
||||||
const [lastPressures, setLastPressures] = useState({ p1: 0, p2: 0 });
|
const [lastPressures, setLastPressures] = useState({ p1: 0, p2: 0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
//addresses of controller LEDs on a V1 device
|
||||||
|
let redAddr = "3-1-512";
|
||||||
|
let greenAddr = "3-1-1024";
|
||||||
if (comprehensiveDevice.device) {
|
if (comprehensiveDevice.device) {
|
||||||
setDevice(Device.any(comprehensiveDevice.device));
|
setDevice(Device.any(comprehensiveDevice.device));
|
||||||
|
|
||||||
|
//check if the device is a MiPCA V2 device since the lEDs will have different addresses
|
||||||
|
let dev = Device.create(comprehensiveDevice.device);
|
||||||
|
if (dev.settings.product === pond.DeviceProduct.DEVICE_PRODUCT_MIPCA_V2) {
|
||||||
|
redAddr = "3-1-256";
|
||||||
|
greenAddr = "3-1-512";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (comprehensiveDevice.components) {
|
if (comprehensiveDevice.components) {
|
||||||
let components: Map<string, Component> = new Map<string, Component>();
|
let components: Map<string, Component> = new Map<string, Component>();
|
||||||
|
|
@ -100,6 +112,11 @@ export default function GateDevice(props: Props) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (c.locationString() === redAddr && c.subType() === 1) {
|
||||||
|
setRedLightKey(c.key());
|
||||||
|
} else if (c.locationString() === greenAddr && c.subType() === 1) {
|
||||||
|
setGreenLightKey(c.key());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
setComponentOptions(components);
|
setComponentOptions(components);
|
||||||
}
|
}
|
||||||
|
|
@ -362,6 +379,9 @@ export default function GateDevice(props: Props) {
|
||||||
}}
|
}}
|
||||||
ambient={ambientKey}
|
ambient={ambientKey}
|
||||||
pressure={pressureKey}
|
pressure={pressureKey}
|
||||||
|
tempChain={tempKey}
|
||||||
|
redKey={redLightKey}
|
||||||
|
greenKey={greenLightKey}
|
||||||
device={device.id()}
|
device={device.id()}
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ interface Props {
|
||||||
device: string | number;
|
device: string | number;
|
||||||
pressure: string;
|
pressure: string;
|
||||||
ambient: string;
|
ambient: string;
|
||||||
|
tempChain: string;
|
||||||
|
redKey: string;
|
||||||
|
greenKey: string;
|
||||||
setPCAState: (state: boolean) => void;
|
setPCAState: (state: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,7 +63,7 @@ const useStyles = makeStyles((theme: Theme) => ({
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function GateGraphs(props: Props) {
|
export default function GateGraphs(props: Props) {
|
||||||
const { gate, display, compMap, device, pressure, ambient, setPCAState } = props;
|
const { gate, display, compMap, device, pressure, ambient, tempChain, greenKey, redKey, setPCAState } = props;
|
||||||
const [{ as }] = useGlobalState();
|
const [{ as }] = useGlobalState();
|
||||||
const [xDomain, setXDomain] = useState<string[] | number[]>(["dataMin", "dataMax"]);
|
const [xDomain, setXDomain] = useState<string[] | number[]>(["dataMin", "dataMax"]);
|
||||||
const [zoomed, setZoomed] = useState(false);
|
const [zoomed, setZoomed] = useState(false);
|
||||||
|
|
@ -270,42 +273,68 @@ export default function GateGraphs(props: Props) {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const sensorGraphs = () => {
|
const graphCard = (component: Component, unitMeasurements: UnitMeasurement[]) => {
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Card raised key={component.key()} style={{ padding: 10, marginBottom: 15 }}>
|
||||||
{Array.from(compMeasurements.values()).map((compMeasurement, i) => {
|
{graphHeader(component)}
|
||||||
let c = Component.create();
|
{unitMeasurements.map(um => {
|
||||||
if (compMeasurement[0]) {
|
if (um.values[0] && um.values[0].values.length > 1) {
|
||||||
c = compMap.get(compMeasurement[0].componentId) ?? Component.create();
|
return areaGraph(
|
||||||
}
|
um,
|
||||||
if (compMap.get(c.key())) {
|
describeMeasurement(um.type, component.type(), component.subType()),
|
||||||
return (
|
component.settings.smoothingAverages
|
||||||
<Card raised key={i} style={{ padding: 10, marginBottom: 15 }}>
|
|
||||||
{graphHeader(c)}
|
|
||||||
{compMeasurement.map(um => {
|
|
||||||
if (um.values[0] && um.values[0].values.length > 1) {
|
|
||||||
return areaGraph(
|
|
||||||
um,
|
|
||||||
describeMeasurement(um.type, c.type(), c.subType()),
|
|
||||||
c.settings.smoothingAverages
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return lineGraph(
|
|
||||||
um,
|
|
||||||
describeMeasurement(um.type, c.type(), c.subType()),
|
|
||||||
c.settings.smoothingAverages
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return <Box></Box>;
|
return lineGraph(
|
||||||
|
um,
|
||||||
|
describeMeasurement(um.type, component.type(), component.subType()),
|
||||||
|
component.settings.smoothingAverages
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function creates the charts for the gate page using the MAIN components on the gate, it DOES NOT show any other components that could be on the device
|
||||||
|
* @returns list of cards for the charts
|
||||||
|
*/
|
||||||
|
const sensorGraphs = () => {
|
||||||
|
let graphs: JSX.Element[] = []
|
||||||
|
|
||||||
|
//pressure
|
||||||
|
let pressureComp = compMap.get(pressure)
|
||||||
|
let pressureReadings = compMeasurements.get(pressure)
|
||||||
|
if(pressureComp && pressureReadings){
|
||||||
|
graphs.push(graphCard(pressureComp, pressureReadings))
|
||||||
|
}
|
||||||
|
//T/H chain
|
||||||
|
let tempComp = compMap.get(tempChain)
|
||||||
|
let tempReadings = compMeasurements.get(tempChain)
|
||||||
|
if(tempComp && tempReadings){
|
||||||
|
graphs.push(graphCard(tempComp, tempReadings))
|
||||||
|
}
|
||||||
|
//ambient
|
||||||
|
let ambientComp = compMap.get(ambient)
|
||||||
|
let ambientReadings = compMeasurements.get(ambient)
|
||||||
|
if (ambientComp && ambientReadings){
|
||||||
|
graphs.push(graphCard(ambientComp, ambientReadings))
|
||||||
|
}
|
||||||
|
//green
|
||||||
|
let greenComp = compMap.get(greenKey)
|
||||||
|
let greenReadings = compMeasurements.get(greenKey)
|
||||||
|
if (greenComp && greenReadings){
|
||||||
|
graphs.push(graphCard(greenComp, greenReadings))
|
||||||
|
}
|
||||||
|
//red
|
||||||
|
let redComp = compMap.get(redKey)
|
||||||
|
let redReadings = compMeasurements.get(redKey)
|
||||||
|
if (redComp && redReadings){
|
||||||
|
graphs.push(graphCard(redComp, redReadings))
|
||||||
|
}
|
||||||
|
return graphs
|
||||||
|
}
|
||||||
|
|
||||||
const analyticGraphs = () => {
|
const analyticGraphs = () => {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ import { CheckCircleOutline, DoNotDisturb, ErrorOutline, HelpOutlineOutlined, Se
|
||||||
import { cloneDeep } from "lodash";
|
import { cloneDeep } from "lodash";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
import { pond } from "protobuf-ts/pond";
|
import { pond } from "protobuf-ts/pond";
|
||||||
|
import { UnitMeasurement } from "models/UnitMeasurement";
|
||||||
|
import { quack } from "protobuf-ts/quack";
|
||||||
|
import { getTemperatureUnit } from "utils";
|
||||||
|
import { teal } from "@mui/material/colors";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
//gates: Gate[];
|
//gates: Gate[];
|
||||||
|
|
@ -47,6 +51,7 @@ export default function GateList(props: Props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isMobile = useMobile();
|
const isMobile = useMobile();
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
|
const [{user}] = useGlobalState()
|
||||||
const [gateDialog, setGateDialog] = useState(false);
|
const [gateDialog, setGateDialog] = useState(false);
|
||||||
const [tablePage, setTablePage] = useState(0)
|
const [tablePage, setTablePage] = useState(0)
|
||||||
const [pageSize, setPageSize] = useState(10)
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
|
@ -104,6 +109,38 @@ export default function GateList(props: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastOutletReading = (reading: pond.UnitMeasurementsForComponent[]) => {
|
||||||
|
let outletDisplay = "--"
|
||||||
|
let timeSince = "No Reading Yet"
|
||||||
|
let color = ""
|
||||||
|
reading.forEach(um => {
|
||||||
|
let clone = cloneDeep(um)
|
||||||
|
let measurement = UnitMeasurement.create(clone, user)
|
||||||
|
if(measurement.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE || measurement.type === quack.MeasurementType.MEASUREMENT_TYPE_PRESSURE){
|
||||||
|
let nodes = measurement.values[measurement.values.length-1].values
|
||||||
|
outletDisplay = nodes[nodes.length-1].toFixed(2) + " " + measurement.unit
|
||||||
|
timeSince = moment(measurement.timestamps[measurement.timestamps.length-1]).fromNow()
|
||||||
|
color = measurement.colour
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<Box padding={2}>
|
||||||
|
<Box display="flex" justifyContent="space-between">
|
||||||
|
<Typography>Value:</Typography>
|
||||||
|
<Typography color={color}>{outletDisplay}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box display="flex" justifyContent="space-between">
|
||||||
|
<Typography>Time:</Typography>
|
||||||
|
<Typography>{timeSince}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CtoF = (celsius: number) => {
|
||||||
|
return Math.round((celsius * (9 / 5) + 32) * 100) / 100;
|
||||||
|
};
|
||||||
|
|
||||||
const desktopCols = (): Column<Gate>[] => {
|
const desktopCols = (): Column<Gate>[] => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|
@ -126,68 +163,78 @@ export default function GateList(props: Props) {
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Duct Type",
|
|
||||||
render: gate => (
|
|
||||||
<Box padding={2}>
|
|
||||||
<Typography>
|
|
||||||
{gate.settings.ductName}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Duct Size(mm)",
|
|
||||||
render: gate => (
|
|
||||||
<Box padding={2}>
|
|
||||||
<Typography>
|
|
||||||
{gate.ductDiameter()}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Duct Length(m)",
|
|
||||||
render: gate => (
|
|
||||||
<Box padding={2}>
|
|
||||||
<Typography>
|
|
||||||
{gate.settings.ductLength}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "PCA Unit",
|
|
||||||
render: gate => (
|
|
||||||
<Box padding={2}>
|
|
||||||
<Typography>
|
|
||||||
{gate.settings.pcaType}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "PCA Status",
|
title: "PCA Status",
|
||||||
render: gate => (
|
render: gate => {
|
||||||
<Box padding={2}>
|
return (<Box padding={2}>
|
||||||
{displayPCAStatus(gate.status.pcaState)}
|
{displayPCAStatus(gate.status.pcaState)}
|
||||||
|
</Box>)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Conditioning",
|
||||||
|
render: gate => {
|
||||||
|
let display = ""
|
||||||
|
if(gate.status.pcaState === pond.PCAState.PCA_STATE_OFF){
|
||||||
|
display = "Inactive"
|
||||||
|
} else if (gate.status.pcaState === pond.PCAState.PCA_STATE_UNKNOWN){
|
||||||
|
display = "--"
|
||||||
|
} else {
|
||||||
|
display = gate.status.heating ? "Heating" : "Cooling"
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box padding={2}>
|
||||||
|
<Typography>
|
||||||
|
{display}
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Estimated Temp",
|
||||||
|
render: gate => {
|
||||||
|
let display = "--"
|
||||||
|
if(gate.status.pcaState !== pond.PCAState.PCA_STATE_OFF){
|
||||||
|
//only display it if the final temp is between -4 and 60 C, is a valid number, and is not exactly 0
|
||||||
|
//0 is technically a valid number but the likely hood of the calculation being exactly 0 is negligible
|
||||||
|
if (gate.status.finalTemp < 60 && gate.status.finalTemp > -40 && !isNaN(gate.status.finalTemp) && gate.status.finalTemp !== 0){
|
||||||
|
display = getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? CtoF(gate.status.finalTemp).toFixed(2) + "°F" : gate.status.finalTemp.toFixed(2) + "°C"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box padding={2}>
|
||||||
|
<Typography >
|
||||||
|
{display}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Last FLow",
|
title: "Last FLow",
|
||||||
render: gate => (
|
render: gate => (
|
||||||
<Box padding={2}>
|
<Box padding={2}>
|
||||||
<Box display="flex" justifyContent="space-between">
|
<Box display="flex" justifyContent="space-between">
|
||||||
<Typography>Last Flow:</Typography>
|
<Typography>Flow:</Typography>
|
||||||
<Typography>{gate.status.lastMassAirflow.toFixed(2)}</Typography>
|
<Typography color={teal[500]}>{gate.status.lastMassAirflow.toFixed(2)} kg/s</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Box display="flex" justifyContent="space-between">
|
<Box display="flex" justifyContent="space-between">
|
||||||
<Typography>Last Reading:</Typography>
|
<Typography>Read:</Typography>
|
||||||
<Typography>{gate.status.lastUpdate !== "" ? moment(gate.status.lastUpdate).fromNow() : "No Reading Yet"}</Typography>
|
<Typography>{gate.status.lastUpdate !== "" ? moment(gate.status.lastUpdate).fromNow() : "No Reading Yet"}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Outlet Temperature",
|
||||||
|
render: gate => (
|
||||||
|
<Box>{lastOutletReading(gate.status.lastTempReading)}</Box>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Outlet Pressure",
|
||||||
|
render: gate => (
|
||||||
|
<Box>{lastOutletReading(gate.status.lastPressureReading)}</Box>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,9 @@ export default function Gate(props: Props) {
|
||||||
const deviceDrawer = () => {
|
const deviceDrawer = () => {
|
||||||
return (
|
return (
|
||||||
<DeviceLinkDrawer
|
<DeviceLinkDrawer
|
||||||
deviceTags={["omniair", "mipca"]}
|
//the mipca tag was not working for some reason so just taking the tags out
|
||||||
|
//and this way we dont have to worry about tagging them when they are provisioned
|
||||||
|
//deviceTags={["omniair", "mipca", "MiPCA", "Mipca"]}
|
||||||
devicePrefMap={devPrefs}
|
devicePrefMap={devPrefs}
|
||||||
prefOptions={[
|
prefOptions={[
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|
@ -341,16 +343,14 @@ export default function Gate(props: Props) {
|
||||||
spacing={2}
|
spacing={2}
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
alignContent="center">
|
alignContent="center">
|
||||||
<Grid>
|
<Grid size={12}>
|
||||||
<Box display="flex" justifyContent="center" alignItems="center">
|
<Box display="flex" justifyContent="center" alignItems="center">
|
||||||
<Link style={{ height: 50, width: 50 }} />
|
<Link style={{ height: 50, width: 50 }} />
|
||||||
|
<Typography paddingLeft={2} style={{ fontSize: 25, fontWeight: 650 }}>
|
||||||
|
Connect Device
|
||||||
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
|
||||||
<Typography style={{ fontSize: 25, fontWeight: 650 }}>
|
|
||||||
Connect Device
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid size={12}>
|
<Grid size={12}>
|
||||||
Click here to add a device to the gate. To add an additional device to a gate
|
Click here to add a device to the gate. To add an additional device to a gate
|
||||||
use the "Link Device" icon on the top right side of this page
|
use the "Link Device" icon on the top right side of this page
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue