Rename js directory to webapp
This commit is contained in:
parent
375bcf4e73
commit
b2eb1e0f05
75 changed files with 5 additions and 5 deletions
|
|
@ -0,0 +1,167 @@
|
|||
<template>
|
||||
<div
|
||||
id="CSMSettings"
|
||||
class="uk-grid uk-grid-divider uk-child-width-expand"
|
||||
uk-grid
|
||||
>
|
||||
<div class="uk-width-large">
|
||||
<h3>Camera/stage mapping</h3>
|
||||
<p>
|
||||
Camera/stage mapping allows the stage to move relative to the camera
|
||||
view. This enables functions like click-to-move, and more precise tile
|
||||
scans.
|
||||
</p>
|
||||
<CSMCalibrationSettings />
|
||||
</div>
|
||||
<div id="mini-stream">
|
||||
<miniStreamDisplay />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import CSMCalibrationSettings from "./CSMSettingsComponents/CSMCalibrationSettings.vue";
|
||||
import miniStreamDisplay from "../../genericComponents/miniStreamDisplay.vue";
|
||||
|
||||
// Export main app
|
||||
export default {
|
||||
name: "CSMSettings",
|
||||
|
||||
components: {
|
||||
CSMCalibrationSettings,
|
||||
miniStreamDisplay
|
||||
},
|
||||
|
||||
props: {
|
||||
showExtraSettings: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
settings: null,
|
||||
recalibrationLinks: {},
|
||||
isCalibrating: false,
|
||||
dataAvailable: false
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
settingsUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
|
||||
},
|
||||
pluginsUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/extensions`;
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.updateSettings();
|
||||
this.updateRecalibrationLinks();
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateSettings: function() {
|
||||
// Update links
|
||||
axios
|
||||
.get(this.settingsUri)
|
||||
.then(response => {
|
||||
this.settings =
|
||||
response.data.extensions["org.openflexure.camera_stage_mapping"];
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
this.settings = {};
|
||||
});
|
||||
},
|
||||
|
||||
updateCalibrationDataAvailability: function() {
|
||||
if ("get_calibration" in this.recalibrationLinks) {
|
||||
axios
|
||||
.get(this.recalibrationLinks.get_calibration.href)
|
||||
.then(response => {
|
||||
if (Object.keys(response.data).length === 0) {
|
||||
this.dataAvailable = false;
|
||||
} else {
|
||||
this.dataAvailable = true;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getCalibrationData: function() {
|
||||
axios
|
||||
.get(this.recalibrationLinks.get_calibration.href)
|
||||
.then(response => {
|
||||
if (response.data != {}) {
|
||||
const data = JSON.stringify(response.data);
|
||||
const url = window.URL.createObjectURL(new Blob([data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", "csm_calibration.json");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
|
||||
updateRecalibrationLinks: function() {
|
||||
axios
|
||||
.get(this.pluginsUri) // Get a list of plugins
|
||||
.then(response => {
|
||||
var plugins = response.data;
|
||||
var foundExtension = plugins.find(
|
||||
e => e.title === "org.openflexure.camera_stage_mapping"
|
||||
);
|
||||
// if camera-stage mapping extension is enabled
|
||||
if (foundExtension) {
|
||||
// Get plugin action link
|
||||
this.recalibrationLinks = foundExtension.links;
|
||||
// Update whether calibration data is available
|
||||
this.updateCalibrationDataAvailability();
|
||||
} else {
|
||||
this.recalibrationLinks = {};
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
|
||||
onRecalibrateResponse: function() {
|
||||
this.modalNotify("Finished stage-to-camera calibration.");
|
||||
// Update local settings
|
||||
this.updateSettings();
|
||||
},
|
||||
|
||||
onRecalibrateError: function(error) {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.center-spinner {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
#mini-stream {
|
||||
min-width: 300px;
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-top: 50px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
<template>
|
||||
<div id="CSMCalibrationSettings">
|
||||
<!--Show auto calibrate if default plugin is enabled-->
|
||||
<div v-if="'calibrate_xy' in recalibrationLinks" class="uk-margin-small">
|
||||
<taskSubmitter
|
||||
:button-primary="true"
|
||||
:can-terminate="false"
|
||||
:requires-confirmation="true"
|
||||
:confirmation-message="
|
||||
'Start recalibration of the stage to the camera? This may take a while, and the microscope will be locked during this time.'
|
||||
"
|
||||
:submit-url="recalibrationLinks.calibrate_xy.href"
|
||||
:submit-label="'Auto-Calibrate using camera'"
|
||||
@response="onRecalibrateResponse"
|
||||
@error="modalError"
|
||||
>
|
||||
</taskSubmitter>
|
||||
</div>
|
||||
<button
|
||||
v-if="'get_calibration' in recalibrationLinks"
|
||||
v-show="dataAvailable && showExtraSettings"
|
||||
type="button"
|
||||
class="uk-button uk-button-default uk-width-1-1"
|
||||
@click="getCalibrationData()"
|
||||
>
|
||||
Download calibration data
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import taskSubmitter from "../../../genericComponents/taskSubmitter";
|
||||
|
||||
// Export main app
|
||||
export default {
|
||||
name: "CSMCalibrationSettings",
|
||||
|
||||
components: {
|
||||
taskSubmitter
|
||||
},
|
||||
|
||||
props: {
|
||||
showExtraSettings: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
settings: null,
|
||||
recalibrationLinks: {},
|
||||
isCalibrating: false,
|
||||
dataAvailable: false
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
settingsUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
|
||||
},
|
||||
pluginsUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/extensions`;
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.updateSettings();
|
||||
this.updateRecalibrationLinks();
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateSettings: function() {
|
||||
// Update links
|
||||
axios
|
||||
.get(this.settingsUri)
|
||||
.then(response => {
|
||||
this.settings =
|
||||
response.data.extensions["org.openflexure.camera_stage_mapping"];
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
this.settings = {};
|
||||
});
|
||||
},
|
||||
|
||||
updateCalibrationDataAvailability: function() {
|
||||
if ("get_calibration" in this.recalibrationLinks) {
|
||||
axios
|
||||
.get(this.recalibrationLinks.get_calibration.href)
|
||||
.then(response => {
|
||||
if (Object.keys(response.data).length === 0) {
|
||||
this.dataAvailable = false;
|
||||
} else {
|
||||
this.dataAvailable = true;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
getCalibrationData: function() {
|
||||
axios
|
||||
.get(this.recalibrationLinks.get_calibration.href)
|
||||
.then(response => {
|
||||
if (response.data != {}) {
|
||||
const data = JSON.stringify(response.data);
|
||||
const url = window.URL.createObjectURL(new Blob([data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", "csm_calibration.json");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
|
||||
updateRecalibrationLinks: function() {
|
||||
axios
|
||||
.get(this.pluginsUri) // Get a list of plugins
|
||||
.then(response => {
|
||||
var plugins = response.data;
|
||||
var foundExtension = plugins.find(
|
||||
e => e.title === "org.openflexure.camera_stage_mapping"
|
||||
);
|
||||
// if camera-stage mapping extension is enabled
|
||||
if (foundExtension) {
|
||||
// Get plugin action link
|
||||
this.recalibrationLinks = foundExtension.links;
|
||||
// Update whether calibration data is available
|
||||
this.updateCalibrationDataAvailability();
|
||||
} else {
|
||||
this.recalibrationLinks = {};
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
|
||||
onRecalibrateResponse: function() {
|
||||
this.modalNotify("Finished stage-to-camera calibration.");
|
||||
// Update local settings
|
||||
this.updateSettings();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.center-spinner {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<template>
|
||||
<div id="appSettings" class="uk-width-large">
|
||||
<h3>Appearance</h3>
|
||||
<p>
|
||||
<label>
|
||||
Theme
|
||||
<select v-model="appTheme" class="uk-select">
|
||||
<option value="light">Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
</label>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// Export main app
|
||||
export default {
|
||||
name: "AppSettings",
|
||||
|
||||
data: function() {
|
||||
return {};
|
||||
},
|
||||
|
||||
computed: {
|
||||
appTheme: {
|
||||
get() {
|
||||
return this.$store.state.appTheme;
|
||||
},
|
||||
set(value) {
|
||||
this.$store.commit("changeAppTheme", value);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
appTheme: function() {
|
||||
this.setLocalStorageObj("appTheme", this.appTheme);
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// Try loading settings from localStorage. If null, don't change.
|
||||
this.appTheme = this.getLocalStorageObj("appTheme") || this.appTheme;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less"></style>
|
||||
|
|
@ -0,0 +1,327 @@
|
|||
<template>
|
||||
<div id="cameraSettings">
|
||||
<div class="uk-grid uk-grid-divider uk-child-width-expand" uk-grid>
|
||||
<div class="uk-width-large">
|
||||
<h3>Manual camera settings</h3>
|
||||
<form @submit.prevent="applySettingsRequest">
|
||||
<div class="uk-margin-small-bottom">
|
||||
<ul uk-accordion="multiple: true">
|
||||
<li class="uk-open">
|
||||
<a class="uk-accordion-title" href="#">Pi Camera Settings</a>
|
||||
<div class="uk-accordion-content">
|
||||
<div v-if="picamera.shutter_speed !== undefined">
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>Exposure time</label
|
||||
>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="picamera.shutter_speed"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="picamera.analog_gain !== undefined">
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>Analogue gain</label
|
||||
>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="picamera.analog_gain"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
step="0.0000000000000001"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="picamera.digital_gain !== undefined">
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>Digital gain</label
|
||||
>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="picamera.digital_gain"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
step="0.0000000000000001"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="picamera.awb_gains !== undefined">
|
||||
<label class="uk-form-label" for="form-stacked-text">
|
||||
White Balance gains
|
||||
</label>
|
||||
<div class="uk-form-controls">
|
||||
<label class="uk-form-label">R:</label>
|
||||
<input
|
||||
v-model="picamera.awb_gains[0]"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
step="0.0000000000000001"
|
||||
/>
|
||||
<label class="uk-form-label">B:</label>
|
||||
<input
|
||||
v-model="picamera.awb_gains[1]"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
step="0.0000000000000001"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="uk-open">
|
||||
<a class="uk-accordion-title" href="#">Image Quality</a>
|
||||
<div class="uk-accordion-content">
|
||||
<div class="uk-child-width-1-2" uk-grid>
|
||||
<div v-if="jpeg_quality !== undefined">
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>JPEG capture quality (%)</label
|
||||
>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="jpeg_quality"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
step="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="stream_resolution !== undefined">
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>Stream resolution</label
|
||||
>
|
||||
<select
|
||||
v-model="stream_resolution"
|
||||
class="uk-select uk-form-small"
|
||||
>
|
||||
<option
|
||||
v-for="option in resolutionOptions"
|
||||
:key="option.value[0]"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.text }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<a class="uk-accordion-title" href="#">Advanced</a>
|
||||
<div class="uk-accordion-content">
|
||||
<div v-if="mjpeg_bitrate !== undefined">
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>Camera bitrate</label
|
||||
>
|
||||
<select
|
||||
v-model="mjpeg_bitrate"
|
||||
class="uk-select uk-form-small"
|
||||
>
|
||||
<option
|
||||
v-for="option in bitrateOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.text }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="uk-margin-remove">
|
||||
This option sets the target bitrate for camera
|
||||
recordings.<br />
|
||||
<b
|
||||
>Limiting bitrate may impact fast-autofocus
|
||||
reliability.</b
|
||||
>
|
||||
However is guaranteed to fix the live stream disappearing
|
||||
for some highly detailed samples.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="picamera.framerate !== undefined"
|
||||
class="uk-margin-top"
|
||||
>
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>Camera framerate</label
|
||||
>
|
||||
<select
|
||||
v-model="picamera.framerate"
|
||||
class="uk-select uk-form-small"
|
||||
>
|
||||
<option
|
||||
v-for="option in framerateOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.text }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="uk-margin-remove">
|
||||
This option sets the framerate of the Pi Camera video
|
||||
encoder. The actual stream framerate is determined by
|
||||
network speed, and is limited by the encoder framerate.<br />
|
||||
<b
|
||||
>Lowering framerate may impact fast-autofocus
|
||||
accuracy.</b
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="uk-button uk-button-primary uk-margin-small uk-width-1-1"
|
||||
>
|
||||
Apply Settings
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<h3>Automatic calibration</h3>
|
||||
<cameraCalibrationSettings></cameraCalibrationSettings>
|
||||
</div>
|
||||
|
||||
<div id="mini-stream">
|
||||
<miniStreamDisplay />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import cameraCalibrationSettings from "./cameraSettingsComponents/cameraCalibrationSettings.vue";
|
||||
import miniStreamDisplay from "../../genericComponents/miniStreamDisplay.vue";
|
||||
|
||||
// Export main app
|
||||
export default {
|
||||
name: "CameraSettings",
|
||||
|
||||
components: {
|
||||
cameraCalibrationSettings,
|
||||
miniStreamDisplay
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
picamera: {
|
||||
shutter_speed: undefined,
|
||||
analog_gain: undefined,
|
||||
digital_gain: undefined,
|
||||
framerate: undefined,
|
||||
awb_gains: undefined
|
||||
},
|
||||
mjpeg_bitrate: undefined,
|
||||
stream_resolution: undefined,
|
||||
jpeg_quality: undefined,
|
||||
bitrateOptions: [
|
||||
{ text: "Maximum (unlimited)", value: -1 },
|
||||
{ text: "High (25Mbps)", value: 25000000 },
|
||||
{ text: "Normal (17Mbps)", value: 17000000 },
|
||||
{ text: "Low (5Mbps)", value: 5000000 },
|
||||
{ text: "Very low (2.5Mbps)", value: 2500000 }
|
||||
],
|
||||
resolutionOptions: [
|
||||
{ text: "Higher (832, 624)", value: [832, 624] },
|
||||
{ text: "Normal (640, 480)", value: [640, 480] }
|
||||
],
|
||||
framerateOptions: [
|
||||
{ text: "Normal (30fps)", value: 30 },
|
||||
{ text: "Low (15fps)", value: 15 },
|
||||
{ text: "Very low (10fps)", value: 10 }
|
||||
]
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
settingsUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.updateSettings();
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateSettings: function() {
|
||||
axios
|
||||
.get(this.settingsUri)
|
||||
.then(response => {
|
||||
const cameraSettings = response.data.camera;
|
||||
// Get base camera settings
|
||||
this.mjpeg_bitrate = cameraSettings.mjpeg_bitrate;
|
||||
this.jpeg_quality = cameraSettings.jpeg_quality;
|
||||
this.stream_resolution = cameraSettings.stream_resolution;
|
||||
// Get Pi Camera settings if they exist
|
||||
if (cameraSettings.picamera) {
|
||||
this.picamera.analog_gain = cameraSettings.picamera.analog_gain;
|
||||
this.picamera.digital_gain = cameraSettings.picamera.digital_gain;
|
||||
this.picamera.shutter_speed = cameraSettings.picamera.shutter_speed;
|
||||
this.picamera.framerate = cameraSettings.picamera.framerate;
|
||||
this.picamera.awb_gains = cameraSettings.picamera.awb_gains;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
|
||||
applySettingsRequest: function() {
|
||||
// We have to use parseInt/parseFloat because JS sometimes seems to
|
||||
// make the numbers be strings... TypeScript would solve this...
|
||||
var payload = {
|
||||
camera: {
|
||||
mjpeg_bitrate: parseInt(this.mjpeg_bitrate),
|
||||
jpeg_quality: parseInt(this.jpeg_quality),
|
||||
stream_resolution: this.stream_resolution,
|
||||
picamera: {
|
||||
shutter_speed: parseFloat(this.picamera.shutter_speed),
|
||||
analog_gain: parseFloat(this.picamera.analog_gain),
|
||||
digital_gain: parseFloat(this.picamera.digital_gain),
|
||||
framerate: parseInt(this.picamera.framerate),
|
||||
awb_gains: [
|
||||
parseFloat(this.picamera.awb_gains[0]),
|
||||
parseFloat(this.picamera.awb_gains[1])
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
console.log(payload);
|
||||
// Send request
|
||||
axios
|
||||
.put(this.settingsUri, payload)
|
||||
.then(() => {
|
||||
return new Promise(r => setTimeout(r, 500));
|
||||
}) // why is there no built-in for this??!
|
||||
.then(() => {
|
||||
// Update local settings
|
||||
this.updateSettings();
|
||||
this.modalNotify("Camera settings applied.");
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
#mini-stream {
|
||||
min-width: 300px;
|
||||
max-width: 600px;
|
||||
text-align: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-top: 50px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,179 @@
|
|||
<template>
|
||||
<div>
|
||||
<!--Show auto calibrate if default plugin is enabled-->
|
||||
<div v-if="'recalibrate' in recalibrationLinks" class="uk-margin-small">
|
||||
<taskSubmitter
|
||||
:can-terminate="false"
|
||||
:requires-confirmation="true"
|
||||
:confirmation-message="
|
||||
'Start recalibration? This may take a while, and the microscope will be locked during this time.'
|
||||
"
|
||||
:submit-url="recalibrationLinks.recalibrate.href"
|
||||
:submit-label="'Full Auto-Calibrate'"
|
||||
@response="onRecalibrateResponse"
|
||||
@error="modalError"
|
||||
>
|
||||
</taskSubmitter>
|
||||
</div>
|
||||
<div
|
||||
v-if="'auto_exposure_from_raw' in recalibrationLinks"
|
||||
class="uk-margin-small"
|
||||
>
|
||||
<taskSubmitter
|
||||
:can-terminate="false"
|
||||
:requires-confirmation="false"
|
||||
:submit-url="recalibrationLinks.auto_exposure_from_raw.href"
|
||||
:submit-label="'Auto gain & shutter speed'"
|
||||
@response="onRecalibrateResponse"
|
||||
@error="modalError"
|
||||
>
|
||||
</taskSubmitter>
|
||||
</div>
|
||||
<div
|
||||
v-if="'auto_white_balance_from_raw' in recalibrationLinks"
|
||||
class="uk-margin-small"
|
||||
>
|
||||
<taskSubmitter
|
||||
:can-terminate="false"
|
||||
:requires-confirmation="false"
|
||||
:submit-url="recalibrationLinks.auto_white_balance_from_raw.href"
|
||||
:submit-label="'Auto white balance'"
|
||||
@response="onRecalibrateResponse"
|
||||
@error="modalError"
|
||||
>
|
||||
</taskSubmitter>
|
||||
</div>
|
||||
<div
|
||||
v-if="'auto_lens_shading_table' in recalibrationLinks"
|
||||
class="uk-margin-small"
|
||||
>
|
||||
<taskSubmitter
|
||||
:can-terminate="false"
|
||||
:requires-confirmation="true"
|
||||
:confirmation-message="
|
||||
'Is the microscope looking at an evenly illuminated, empty field of view? ' +
|
||||
'If not, the current image will show through in any images captured afterwards.'
|
||||
"
|
||||
:submit-url="recalibrationLinks.auto_lens_shading_table.href"
|
||||
:submit-label="'Auto flat field correction'"
|
||||
@response="onRecalibrateResponse"
|
||||
@error="modalError"
|
||||
>
|
||||
</taskSubmitter>
|
||||
</div>
|
||||
|
||||
<div v-show="showExtraSettings" class="uk-child-width-expand">
|
||||
<button
|
||||
v-if="'flatten_lens_shading_table' in recalibrationLinks"
|
||||
class="uk-button uk-button-danger uk-width-1-1"
|
||||
@click="flattenLensShadingTableRequest"
|
||||
>
|
||||
Disable flat field correction
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="LstDownloadEnabled">
|
||||
<a
|
||||
class="uk-button uk-button-default uk-width-large uk-margin-small-top uk-align-center"
|
||||
:href="LstDownloadUri"
|
||||
download
|
||||
>Download Lens-Shading Table</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import taskSubmitter from "../../../genericComponents/taskSubmitter";
|
||||
|
||||
// Export main app
|
||||
export default {
|
||||
name: "CameraCalibrationSettings",
|
||||
|
||||
components: {
|
||||
taskSubmitter
|
||||
},
|
||||
|
||||
props: {
|
||||
showExtraSettings: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
recalibrationLinks: {},
|
||||
isCalibrating: false,
|
||||
LstDownloadEnabled: false
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
pluginsUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/extensions`;
|
||||
},
|
||||
LstDownloadUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/instrument/camera/lst`;
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.updateRecalibrationLinks();
|
||||
this.checkLstDownload();
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateRecalibrationLinks: function() {
|
||||
axios
|
||||
.get(this.pluginsUri) // Get a list of plugins
|
||||
.then(response => {
|
||||
var plugins = response.data;
|
||||
var foundExtension = plugins.find(
|
||||
e => e.title === "org.openflexure.calibration.picamera"
|
||||
);
|
||||
// if AutocalibrationPlugin is enabled
|
||||
if (foundExtension) {
|
||||
// Get plugin action link
|
||||
this.recalibrationLinks = foundExtension.links;
|
||||
} else {
|
||||
this.recalibrationLinks = {};
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
|
||||
checkLstDownload: function() {
|
||||
axios
|
||||
.get(this.LstDownloadUri) // Get a list of plugins
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
this.LstDownloadEnabled = true;
|
||||
} else {
|
||||
this.LstDownloadEnabled = false;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
|
||||
onRecalibrateResponse: function() {
|
||||
this.modalNotify("Finished recalibration.");
|
||||
},
|
||||
|
||||
flattenLensShadingTableRequest: function() {
|
||||
axios.post(this.recalibrationLinks.flatten_lens_shading_table.href);
|
||||
},
|
||||
deleteLensShadingTableRequest: function() {
|
||||
axios.post(this.recalibrationLinks.delete_lens_shading_table.href);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less"></style>
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
<template>
|
||||
<div id="appSettings" class="uk-width-large">
|
||||
<h3>Additional features</h3>
|
||||
<p class="uk-margin-small">
|
||||
<label
|
||||
><input v-model="IHIEnabled" class="uk-checkbox" type="checkbox" />
|
||||
Enable IHI Slide Scan</label
|
||||
>
|
||||
</p>
|
||||
<p class="uk-margin-small">
|
||||
Enabling IHI Slide Scan will add a new tab designed to simplify acquiring
|
||||
blood smear tile scans. <br />
|
||||
While this may be useful for other applications, it is primarily designed
|
||||
for use in clinics acquiring blood smear samples using a 100x
|
||||
oil-immersion objective, with a Raspberry Pi Camera v2.
|
||||
</p>
|
||||
<p class="uk-margin-small" :class="{ 'uk-text-muted': !imjoyPermitted }">
|
||||
<label :disabled="!imjoyPermitted">
|
||||
<input
|
||||
v-model="imjoyEnabled"
|
||||
class="uk-checkbox"
|
||||
type="checkbox"
|
||||
:disabled="!imjoyPermitted"
|
||||
/>
|
||||
Enable ImJoy plugin engine
|
||||
</label>
|
||||
</p>
|
||||
<p v-if="imjoyPermitted" class="uk-margin-small">
|
||||
<a href="https://imjoy.io/">ImJoy</a> enables integration with a wide
|
||||
variety of microscopy and image analysis applications, including ImageJ.JS
|
||||
and Kaibu. Support for ImJoy within the OFM software is currently
|
||||
experimental, and it may slow down loading of the application.
|
||||
</p>
|
||||
<p v-if="!imjoyPermitted" class="uk-margin-small uk-text-muted">
|
||||
ImJoy plugins are disabled in this build of the OpenFlexure software.
|
||||
</p>
|
||||
<p class="uk-margin-small">
|
||||
<label
|
||||
><input v-model="galleryEnabled" class="uk-checkbox" type="checkbox" />
|
||||
Enable Gallery</label
|
||||
>
|
||||
</p>
|
||||
<p class="uk-margin-small">
|
||||
The "gallery" tab can cause performance issues; un-tick the box above to
|
||||
disable it.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// Export main app
|
||||
export default {
|
||||
name: "FeaturesSettings",
|
||||
|
||||
data: function() {
|
||||
return {};
|
||||
},
|
||||
|
||||
computed: {
|
||||
IHIEnabled: {
|
||||
get() {
|
||||
return this.$store.state.IHIEnabled;
|
||||
},
|
||||
set(value) {
|
||||
this.$store.commit("changeIHIEnabled", value);
|
||||
}
|
||||
},
|
||||
imjoyEnabled: {
|
||||
get() {
|
||||
return this.$store.state.imjoyEnabled;
|
||||
},
|
||||
set(value) {
|
||||
this.$store.commit("changeImjoyEnabled", value);
|
||||
}
|
||||
},
|
||||
galleryEnabled: {
|
||||
get() {
|
||||
return this.$store.state.galleryEnabled;
|
||||
},
|
||||
set(value) {
|
||||
this.$store.commit("changeGalleryEnabled", value);
|
||||
}
|
||||
},
|
||||
imjoyPermitted: function() {
|
||||
// ImJoy may be disabled using an environment variable.
|
||||
// If this function returns false, it is never possible to
|
||||
// use ImJoy, so we should gray out the option.
|
||||
return process.env.VUE_APP_ENABLE_IMJOY === "true";
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
IHIEnabled: function() {
|
||||
this.setLocalStorageObj("IHIEnabled", this.IHIEnabled);
|
||||
},
|
||||
imjoyEnabled: function() {
|
||||
this.setLocalStorageObj("imjoyEnabled", this.imjoyEnabled);
|
||||
},
|
||||
galleryEnabled: function() {
|
||||
this.setLocalStorageObj("galleryEnabled", this.galleryEnabled);
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// Try loading settings from localStorage. If null, don't change.
|
||||
this.IHIEnabled = this.getLocalStorageObj("IHIEnabled") || this.IHIEnabled;
|
||||
this.imjoyEnabled =
|
||||
this.getLocalStorageObj("imjoyEnabled") || this.imjoyEnabled;
|
||||
this.galleryEnabled =
|
||||
this.getLocalStorageObj("galleryEnabled") || this.galleryEnabled;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less"></style>
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<template>
|
||||
<div v-if="settings" id="microscopeSettings" class="uk-width-large">
|
||||
<form @submit.prevent="applySettingsRequest">
|
||||
<div>
|
||||
<label class="uk-form-label" for="form-stacked-text"
|
||||
>Microscope name</label
|
||||
>
|
||||
<input
|
||||
v-model="settings.name"
|
||||
class="uk-input uk-width-1-1 uk-form-small"
|
||||
name="inputFilename"
|
||||
placeholder="Leave blank for default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="uk-button uk-button-primary uk-margin-small uk-width-1-1"
|
||||
>
|
||||
Apply Settings
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
|
||||
// Export main app
|
||||
export default {
|
||||
name: "MicroscopeSettings",
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
settings: null
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
settingsUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.updateSettings();
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateSettings: function() {
|
||||
axios
|
||||
.get(this.settingsUri)
|
||||
.then(response => {
|
||||
this.settings = response.data;
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
|
||||
applySettingsRequest: function() {
|
||||
var payload = {
|
||||
name: this.settings.name
|
||||
};
|
||||
|
||||
// Send request to update config
|
||||
axios
|
||||
.put(this.settingsUri, payload)
|
||||
.then(() => {
|
||||
// Update local settings
|
||||
this.updateSettings();
|
||||
this.modalNotify("Microscope settings applied.");
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less">
|
||||
.center-spinner {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
<template>
|
||||
<div id="stageSettings" class="uk-width-large">
|
||||
<div v-if="stageType === 'MissingStage'" class="uk-text-danger">
|
||||
<b>No stage connected</b>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h3>Stage settings</h3>
|
||||
<form @submit.prevent="setStageType">
|
||||
<div class="uk-margin-small-bottom">
|
||||
<h4>Stage geometry</h4>
|
||||
<select v-model="stageType" class="uk-select uk-margin-small-top">
|
||||
<option value="SangaStage">SangaStage (Standard)</option>
|
||||
<option value="SangaDeltaStage"> SangaStage (Delta)</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="uk-button uk-button-primary uk-margin-small uk-width-1-1"
|
||||
>
|
||||
Change stage geometry
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form @submit.prevent="applySettingsRequest">
|
||||
<div class="uk-margin-small-bottom">
|
||||
<h4>Backlash compensation</h4>
|
||||
<p class="uk-margin-remove">
|
||||
Backlash compentation causes movements to overshoot by that number
|
||||
of motor steps, reducing the effect of mechanical backlash.
|
||||
</p>
|
||||
<div
|
||||
class="uk-grid-small uk-child-width-1-3 uk-margin-small-bottom"
|
||||
uk-grid
|
||||
>
|
||||
<div>
|
||||
<label class="uk-form-label" for="form-stacked-text">x</label>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="backlash.x"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="uk-form-label" for="form-stacked-text">y</label>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="backlash.y"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="uk-form-label" for="form-stacked-text">z</label>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="backlash.z"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4>Settle time</h4>
|
||||
<div v-if="settle_time !== undefined">
|
||||
<p class="uk-margin-small-bottom">
|
||||
When moving or scanning, captures will be delayed by the settle
|
||||
time (in seconds) to reduce motion blur.
|
||||
</p>
|
||||
<div class="uk-form-controls">
|
||||
<input
|
||||
v-model="settle_time"
|
||||
class="uk-input uk-form-small"
|
||||
type="number"
|
||||
step="0.1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="uk-button uk-button-primary uk-margin-small uk-width-1-1"
|
||||
>
|
||||
Apply Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
|
||||
export default {
|
||||
name: "StageSettings",
|
||||
|
||||
components: {},
|
||||
|
||||
data: function() {
|
||||
return {
|
||||
stageType: "MissingStage",
|
||||
backlash: {
|
||||
x: undefined,
|
||||
y: undefined,
|
||||
z: undefined
|
||||
},
|
||||
settle_time: undefined
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
settingsUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/instrument/settings`;
|
||||
},
|
||||
stageTypeUri: function() {
|
||||
return `${this.$store.getters.baseUri}/api/v2/instrument/stage/type`;
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.getStageType();
|
||||
this.updateSettings();
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateSettings: function() {
|
||||
axios
|
||||
.get(this.settingsUri)
|
||||
.then(response => {
|
||||
const stageSettings = response.data.stage;
|
||||
this.backlash.x = stageSettings.backlash.x;
|
||||
this.backlash.y = stageSettings.backlash.y;
|
||||
this.backlash.z = stageSettings.backlash.z;
|
||||
this.settle_time = stageSettings.settle_time;
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
applySettingsRequest: function() {
|
||||
var payload = {
|
||||
stage: {
|
||||
backlash: this.backlash
|
||||
}
|
||||
};
|
||||
// Send request to update settings
|
||||
axios
|
||||
.put(this.settingsUri, payload)
|
||||
.then(() => {
|
||||
// Update local settings
|
||||
this.updateSettings();
|
||||
this.modalNotify("Stage settings applied.");
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error); // Let mixin handle error
|
||||
});
|
||||
},
|
||||
getStageType: function() {
|
||||
axios
|
||||
.get(this.stageTypeUri)
|
||||
.then(response => {
|
||||
this.stageType = response.data;
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error);
|
||||
});
|
||||
},
|
||||
setStageType: function() {
|
||||
axios
|
||||
.put(this.stageTypeUri, this.stageType, {
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
this.stageType = response.data;
|
||||
|
||||
this.modalNotify("Stage geometry changed.");
|
||||
})
|
||||
.catch(error => {
|
||||
this.modalError(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less"></style>
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
<template>
|
||||
<div id="streamSettings" class="uk-width-large">
|
||||
<div>
|
||||
<h3>Stream settings</h3>
|
||||
<label
|
||||
><input v-model="disableStream" class="uk-checkbox" type="checkbox" />
|
||||
Disable web stream</label
|
||||
>
|
||||
<p class="uk-margin-small">
|
||||
This will disable the embedded web stream of the camera.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<div>
|
||||
<h3>Microscope display output</h3>
|
||||
<div uk-grid>
|
||||
<div>
|
||||
<label :class="[{ 'uk-disabled': !this.$store.getters.ready }]"
|
||||
><input
|
||||
v-model="autoGpuPreview"
|
||||
class="uk-checkbox"
|
||||
type="checkbox"
|
||||
/>
|
||||
Enable GPU preview</label
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label :class="[{ 'uk-disabled': !this.$store.getters.ready }]"
|
||||
><input v-model="trackWindow" class="uk-checkbox" type="checkbox" />
|
||||
Track window</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
Enable GPU preview turns on a low-latency camera preview drawn directly
|
||||
to the Raspberry Pi display output.
|
||||
</p>
|
||||
<p class="uk-margin-small">
|
||||
<b
|
||||
>Content, such as your mouse, won't be visible behind this preview.</b
|
||||
>
|
||||
Track Window will attempt to resize the GPU preview based on the current
|
||||
window size, however this is often imperfect and cannot track window
|
||||
movement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// Export main app
|
||||
export default {
|
||||
name: "StreamSettings",
|
||||
|
||||
data: function() {
|
||||
return {};
|
||||
},
|
||||
|
||||
computed: {
|
||||
disableStream: {
|
||||
get() {
|
||||
return this.$store.state.disableStream;
|
||||
},
|
||||
set(value) {
|
||||
this.$store.commit("changeDisableStream", value);
|
||||
}
|
||||
},
|
||||
|
||||
autoGpuPreview: {
|
||||
get() {
|
||||
return this.$store.state.autoGpuPreview;
|
||||
},
|
||||
set(value) {
|
||||
// NB the stream viewer watches the store, and is
|
||||
// responsible for making the request that switches
|
||||
// GPU preview on/off
|
||||
// see streamContent.vue
|
||||
this.$store.commit("changeAutoGpuPreview", value);
|
||||
}
|
||||
},
|
||||
|
||||
trackWindow: {
|
||||
get() {
|
||||
return this.$store.state.trackWindow;
|
||||
},
|
||||
set(value) {
|
||||
this.$store.commit("changeTrackWindow", value);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
// Cache the stream settings to local storage for persistence
|
||||
// (the next 3 functions all relate to this)
|
||||
disableStream: function(newValue) {
|
||||
this.setLocalStorageObj("disableStream", newValue);
|
||||
},
|
||||
autoGpuPreview: function(newValue) {
|
||||
this.setLocalStorageObj("autoGpuPreview", newValue);
|
||||
},
|
||||
trackWindow: function(newValue) {
|
||||
this.setLocalStorageObj("trackWindow", newValue);
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
// Apply sensible defaults for stream settings, depending on
|
||||
// whether we're connecting locally or remotely, respecting
|
||||
// the settings that were cached previously.
|
||||
const localMode = ["localhost", "0.0.0.0", "127.0.0.1", "[::1]"].includes(
|
||||
window.location.hostname
|
||||
);
|
||||
const localDefaults = {
|
||||
disableStream: true,
|
||||
autoGpuPreview: true,
|
||||
trackWindow: true
|
||||
};
|
||||
for (let k in localDefaults) {
|
||||
if (localStorage.getItem(k) !== null) {
|
||||
this[k] = this.getLocalStorageObj(k);
|
||||
} else if (localMode) {
|
||||
console.log(`${k} set to default value for a local connection`);
|
||||
this[k] = localDefaults[k];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less"></style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue