Merge branch 'refactor/migrate_vuex_to_pinia' into 'v3'

ui_migration Migrate VUEX to Pinia

Closes #731

See merge request openflexure/openflexure-microscope-server!553
This commit is contained in:
Julian Stirling 2026-05-01 12:52:05 +00:00
commit 964a907edc
38 changed files with 2127 additions and 1548 deletions

View file

@ -18,7 +18,7 @@ include:
.javascript_webapp: .javascript_webapp:
extends: .javascript extends: .javascript
variables: variables:
JS_IMAGE: node:18 JS_IMAGE: node:20
JS_PATH: webapp JS_PATH: webapp
JS_CACHE_PATH: webapp/node_modules JS_CACHE_PATH: webapp/node_modules

View file

@ -1,40 +1,30 @@
import js from "@eslint/js"; import js from "@eslint/js";
import vue from "eslint-plugin-vue"; import vue from "eslint-plugin-vue";
import vueParser from "vue-eslint-parser";
import babelParser from "@babel/eslint-parser";
import prettierPlugin from "eslint-plugin-prettier"; import prettierPlugin from "eslint-plugin-prettier";
import prettierConfig from "eslint-config-prettier"; import prettierConfig from "eslint-config-prettier";
import globals from "globals"; import globals from "globals";
import pinia from "eslint-plugin-pinia";
const isProd = process.env.NODE_ENV === "production"; const isProd = process.env.NODE_ENV === "production";
export default [ export default [
{ {
languageOptions: { ignores: ["dist/**/*", "lib/**/*", "**/*.min.js", "tools/architecture_dashboard/**/*"],
globals: {
...globals.node,
},
},
}, },
js.configs.recommended, js.configs.recommended,
...vue.configs["flat/recommended"], ...vue.configs["flat/recommended"],
prettierConfig,
{ {
files: ["**/*.vue", "**/*.js", "**/*.jsx", "**/*.cjs", "**/*.mjs"], files: ["**/*.vue", "**/*.js", "**/*.jsx", "**/*.cjs", "**/*.mjs"],
languageOptions: { languageOptions: {
parser: vueParser,
parserOptions: {
parser: babelParser,
requireConfigFile: false,
ecmaVersion: 2021,
sourceType: "module",
},
ecmaVersion: 2021, ecmaVersion: 2021,
sourceType: "module", sourceType: "module",
globals: { globals: {
...globals.node,
...globals.browser,
...globals.es2021,
process: "readonly", process: "readonly",
}, },
}, },
@ -42,6 +32,7 @@ export default [
plugins: { plugins: {
vue, vue,
prettier: prettierPlugin, prettier: prettierPlugin,
pinia,
}, },
rules: { rules: {
@ -76,6 +67,11 @@ export default [
"vue/no-unused-components": "warn", "vue/no-unused-components": "warn",
"vue/no-unused-vars": "warn", "vue/no-unused-vars": "warn",
// Pinia best practices
"pinia/prefer-use-store-naming-convention": "warn",
"pinia/require-setup-store-properties-export": "warn",
}, },
}, },
prettierConfig,
]; ];

2444
webapp/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,9 @@
"private": true, "private": true,
"description": "User interface for the OpenFlexure Microscope Server", "description": "User interface for the OpenFlexure Microscope Server",
"author": "OpenFlexure <contact@openflexure.org> (https://www.openflexure.org)", "author": "OpenFlexure <contact@openflexure.org> (https://www.openflexure.org)",
"engines": {
"node": ">=20.0.0"
},
"scripts": { "scripts": {
"gv": "genversion src/version.js", "gv": "genversion src/version.js",
"serve": "vite serve --mode development", "serve": "vite serve --mode development",
@ -18,24 +21,27 @@
"mitt": "^3.0.1", "mitt": "^3.0.1",
"mousetrap": "^1.6.5", "mousetrap": "^1.6.5",
"openseadragon": "^5.0.0", "openseadragon": "^5.0.0",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"uikit": "^3.24.2", "uikit": "^3.24.2",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-router": "4.0.0", "vue-router": "4.0.0"
"vuex": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.28.5", "@babel/eslint-parser": "^7.28.5",
"@vitejs/plugin-vue": "^5.2.4", "@vitejs/plugin-vue": "^6.0.6",
"@vue/eslint-config-prettier": "9.0.0", "@vue/eslint-config-prettier": "9.0.0",
"autoprefixer": "^10.4.24", "autoprefixer": "^10.4.24",
"core-js": "^3.46.0", "core-js": "^3.46.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-plugin-prettier": "^5.1.0", "eslint-plugin-prettier": "^5.1.0",
"eslint-plugin-vue": "^9.20.0", "eslint-plugin-vue": "^9.20.0",
"eslint-plugin-pinia": "^0.4.2",
"typescript-eslint": "^8.59.0",
"less": "^4.5.1", "less": "^4.5.1",
"material-symbols": "^0.39.1", "material-symbols": "^0.39.1",
"prettier": "^3.2.8", "prettier": "^3.2.8",
"vite": "^5.4.21" "vite": "^7.3.1"
}, },
"postcss": { "postcss": {
"plugins": { "plugins": {

View file

@ -2,8 +2,8 @@
<div id="app" class="uk-height-1-1 uk-margin-remove uk-padding-remove" :class="handleTheme"> <div id="app" class="uk-height-1-1 uk-margin-remove uk-padding-remove" :class="handleTheme">
<!-- this stops the app loading until setConnected is committed in the store, this means <!-- this stops the app loading until setConnected is committed in the store, this means
other components will not load until we have Thing Descriptions. --> other components will not load until we have Thing Descriptions. -->
<loadingContent v-if="!$store.getters.ready" /> <loadingContent v-if="!ready" />
<appContent v-if="$store.getters.ready" /> <appContent v-else />
<!-- Runtime modals --> <!-- Runtime modals -->
<div id="modal-center" ref="keyboardManualModal" class="uk-flex-top" uk-modal> <div id="modal-center" ref="keyboardManualModal" class="uk-flex-top" uk-modal>
<div class="uk-modal-dialog uk-modal-body uk-margin-auto-vertical"> <div class="uk-modal-dialog uk-modal-body uk-margin-auto-vertical">
@ -28,6 +28,9 @@ import appContent from "./components/appContent.vue";
import loadingContent from "./components/loadingContent.vue"; import loadingContent from "./components/loadingContent.vue";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import { eventBus } from "./eventBus.js"; import { eventBus } from "./eventBus.js";
import { mapWritableState, mapState, mapActions } from "pinia";
import { useSettingsStore } from "@/stores/settings.js";
import { useWotStore } from "@/stores/wot.js";
const move_keys = ["up", "down", "left", "right", "pageup", "pagedown"]; const move_keys = ["up", "down", "left", "right", "pageup", "pagedown"];
@ -75,6 +78,16 @@ export default {
}, },
computed: { computed: {
...mapWritableState(useSettingsStore, [
"appTheme",
"navigationStepSize",
"navigationInvert",
"microscopeHostname",
"error",
"waiting",
]),
...mapState(useSettingsStore, ["ready", "baseUri"]),
isSystemDark: function () { isSystemDark: function () {
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) {
return true; return true;
@ -84,9 +97,9 @@ export default {
}, },
handleTheme: function () { handleTheme: function () {
var isDark = false; var isDark = false;
if (this.$store.state.appTheme == "dark") { if (this.appTheme == "dark") {
isDark = true; isDark = true;
} else if (this.$store.state.appTheme == "system") { } else if (this.appTheme == "system") {
if (this.systemDark) { if (this.systemDark) {
isDark = true; isDark = true;
} }
@ -98,126 +111,121 @@ export default {
}, },
}, },
// watch origin function refactored
watch: {
baseUri() {
this.checkConnection();
},
},
mounted() { mounted() {
// Query CSS dark theme preference // Query CSS dark theme preference
var mql = window.matchMedia("(prefers-color-scheme: dark)"); this.mql = window.matchMedia("(prefers-color-scheme: dark)");
this.systemDark = this.mql.matches;
// Check for system dark theme when mounted // Check for system dark theme when mounted
if (mql.matches) { if (this.mql.matches) {
this.systemDark = true; this.systemDark = true;
} }
// Create a theme observer to watch for changes // Create a theme observer to watch for changes
this.themeObserver = mql.addListener((e) => { this.themeWatchdog = (e) => {
if (e.matches) { this.systemDark = e.matches;
this.systemDark = true; };
} else { this.mql.addEventListener("change", this.themeWatchdog);
this.systemDark = false;
}
});
// Check connection to API // Check connection to API
this.checkConnection(); this.checkConnection();
}, },
created: function () { created: function () {
window.addEventListener("beforeunload", this.handleExit); window.addEventListener("beforeunload", this.handleExit);
// Scrollwheel listener this.bindHardwareInputs(); // Turn keys ON when connected
window.addEventListener("wheel", this.wheelMonitor);
// Watch for origin changes
this.unwatchOriginFunction = this.$store.watch(
(state, getters) => {
return getters.baseUri;
},
() => {
this.checkConnection();
},
);
// Keyboard shortcuts
Mousetrap.bind("?", () => {
this.toggleModalElement(this.$refs["keyboardManualModal"]); // Calls the mixin
});
Mousetrap.bind(
move_keys,
(event, key) => {
event.preventDefault();
this.keysDown.add(key);
this.updateJogFromKeys();
},
"keydown",
);
Mousetrap.bind(
move_keys,
(event, key) => {
event.preventDefault();
this.keysDown.delete(key);
this.updateJogFromKeys();
},
"keyup",
);
this.keyboardManual.push({
shortcut: "←↑→↓",
description: "Move the microscope stage",
});
this.keyboardManual.push({
shortcut: "pgup / pgdn",
description: "Move the microscope focus",
});
// Capture
Mousetrap.bind("c", () => {
eventBus.emit("globalCaptureEvent", {});
});
this.keyboardManual.push({
shortcut: "c",
description: "Take a capture",
});
// Autofocus
Mousetrap.bind("a", () => {
eventBus.emit("globalFastAutofocusEvent", {});
});
this.keyboardManual.push({
shortcut: "a",
description: "Fast autofocus",
});
// Increment/decrement tab
Mousetrap.bind("shift+down", () => {
eventBus.emit("globalIncrementTab", {});
});
Mousetrap.bind("shift+up", () => {
eventBus.emit("globalDecrementTab", {});
});
this.keyboardManual.push({
shortcut: "shift+↑ / shift+↓",
description: "Switch tab",
});
}, },
beforeUnmount: function () { beforeUnmount: function () {
// Disconnect the theme observer // Disconnect the theme observer
if (this.themeObserver) { this.mql.removeEventListener("change", this.themeWatchdog);
this.themeObserver.disconnect();
}
// Remove scrollwheel listener // Remove scrollwheel listener
window.removeEventListener("wheel", this.wheelMonitor); this.unbindHardwareInputs();
// Remove origin watcher
this.unwatchOriginFunction();
// Remove key listeners
Mousetrap.reset();
}, },
methods: { methods: {
...mapActions(useSettingsStore, ["setConnected"]),
bindHardwareInputs() {
window.addEventListener("wheel", this.wheelMonitor);
Mousetrap.bind("?", () => {
this.toggleModalElement(this.$refs["keyboardManualModal"]);
});
Mousetrap.bind(
move_keys,
(event, key) => {
event.preventDefault();
this.keysDown.add(key);
this.updateJogFromKeys();
},
"keydown",
);
Mousetrap.bind(
move_keys,
(event, key) => {
event.preventDefault();
this.keysDown.delete(key);
this.updateJogFromKeys();
},
"keyup",
);
this.keyboardManual.push({
shortcut: "←↑→↓",
description: "Move the microscope stage",
});
this.keyboardManual.push({
shortcut: "pgup / pgdn",
description: "Move the microscope focus",
});
Mousetrap.bind("c", () => {
eventBus.emit("globalCaptureEvent", {});
});
this.keyboardManual.push({
shortcut: "c",
description: "Take a capture",
});
Mousetrap.bind("a", () => {
eventBus.emit("globalFastAutofocusEvent", {});
});
this.keyboardManual.push({
shortcut: "a",
description: "Fast autofocus",
});
Mousetrap.bind("shift+down", () => {
eventBus.emit("globalIncrementTab", {});
});
Mousetrap.bind("shift+up", () => {
eventBus.emit("globalDecrementTab", {});
});
this.keyboardManual.push({
shortcut: "shift+↑ / shift+↓",
description: "Switch tab",
});
},
unbindHardwareInputs() {
window.removeEventListener("wheel", this.wheelMonitor);
Mousetrap.reset();
this.keyboardManual = [];
},
async checkConnection() { async checkConnection() {
var baseUri = this.$store.getters.baseUri; this.waiting = true;
this.$store.commit("changeWaiting", true); const wotStore = useWotStore();
// TODO: more robust check - e.g. use a microscope Thing
// TODO: should we purge existing consumedThings?
try { try {
await this.$store.dispatch("wot/fetchThingDescriptions", `${baseUri}/thing_descriptions/`); await wotStore.fetchThingDescriptions(`${this.baseUri}/thing_descriptions/`);
for (let requiredThing of ["camera", "system"]) { for (let requiredThing of ["camera", "system"]) {
if (!this.thingAvailable(requiredThing)) { if (!this.thingAvailable(requiredThing)) {
throw new Error(`No ${requiredThing} found, the GUI won't work without one.`); throw new Error(`No ${requiredThing} found, the GUI won't work without one.`);
@ -225,37 +233,32 @@ export default {
} }
try { try {
let hostname = await this.readThingProperty("system", "hostname"); let hostname = await this.readThingProperty("system", "hostname");
this.$store.commit("changeMicroscopeHostname", hostname); this.microscopeHostname = hostname;
document.title = `OpenFlexure Microscope: ${hostname}`; document.title = `OpenFlexure Microscope: ${hostname}`;
} catch { } catch {
this.$store.commit("changeMicroscopeHostname", null); this.microscopeHostname = null;
} }
// start rendering components this.setConnected();
this.$store.commit("setConnected"); this.error = null;
this.$store.commit("setErrorMessage", null);
} catch (error) { } catch (error) {
this.$store.commit("setErrorMessage", error); this.error = error;
} finally { } finally {
this.$store.commit("changeWaiting", false); this.waiting = false;
} }
}, },
handleExit: function () {},
handleExit() {},
/** /**
* Handle global mouse wheel events to be associated with navigation * Handle global mouse wheel events to be associated with navigation
*/ */
wheelMonitor: function (event) { wheelMonitor(event) {
// Only capture scroll if the event target's parent contains the "scrollTarget" class
if ( if (
event.target.parentNode.classList.contains("scrollTarget") || event.target.parentNode.classList.contains("scrollTarget") ||
event.target.classList.contains("scrollTarget") event.target.classList.contains("scrollTarget")
) { ) {
const z_rel = event.deltaY / 100; const z_rel = event.deltaY / 100;
// Emit a signal to move, acted on by panelControl.vue const z = z_rel * this.navigationStepSize.z;
const navigationStepSize = this.$store.state.navigationStepSize;
const z = z_rel * navigationStepSize.z;
// Don't use `jog() due to variable size of jogs here and the rate limiting in
// `jog()`. No need to invert on z, as navigationInvert.z isn't exposed.
this.invokeAction("stage", "jog", { x: 0, y: 0, z: z }); this.invokeAction("stage", "jog", { x: 0, y: 0, z: z });
eventBus.emit("globalUpdatePositionEvent"); eventBus.emit("globalUpdatePositionEvent");
} }
@ -269,18 +272,14 @@ export default {
* request to jog that is too recent after the last jog. * request to jog that is too recent after the last jog.
*/ */
jog(x, y, z) { jog(x, y, z) {
// Manually debounce extra requests from keyboard repeat rate.
// This is used rather than an interval in case of missing a repeat.
const now = Date.now(); const now = Date.now();
const navigationInvert = this.$store.state.navigationInvert;
if (now - this.lastJogTime < this.jogTime) { if (now - this.lastJogTime < this.jogTime) {
return; return;
} }
this.lastJogTime = now; this.lastJogTime = now;
this.invokeAction("stage", "jog", { this.invokeAction("stage", "jog", {
x: x * this.jogDistance * (navigationInvert.x ? -1 : 1), x: x * this.jogDistance * (this.navigationInvert.x ? -1 : 1),
y: y * this.jogDistance * (navigationInvert.y ? -1 : 1), y: y * this.jogDistance * (this.navigationInvert.y ? -1 : 1),
z: z * this.jogDistance, z: z * this.jogDistance,
}); });
eventBus.emit("globalUpdatePositionEvent"); eventBus.emit("globalUpdatePositionEvent");

View file

@ -91,6 +91,7 @@ import slideScanContent from "./tabContentComponents/slideScanContent.vue";
import viewContent from "./tabContentComponents/viewContent.vue"; import viewContent from "./tabContentComponents/viewContent.vue";
import { markRaw } from "vue"; import { markRaw } from "vue";
import { eventBus } from "../eventBus.js"; import { eventBus } from "../eventBus.js";
import { useSettingsStore } from "@/stores/settings.js";
// Import modal components for device initialisation // Import modal components for device initialisation
import calibrationWizard from "./modalComponents/calibrationWizard.vue"; import calibrationWizard from "./modalComponents/calibrationWizard.vue";
@ -196,6 +197,7 @@ export default {
}, },
mounted() { mounted() {
const store = useSettingsStore();
// A global signal listener to switch tab // A global signal listener to switch tab
eventBus.on("globalSwitchTab", (tabID) => { eventBus.on("globalSwitchTab", (tabID) => {
this.currentTab = tabID; this.currentTab = tabID;
@ -208,7 +210,7 @@ export default {
eventBus.on("globalDecrementTab", () => { eventBus.on("globalDecrementTab", () => {
this.incrementTabBy(-1); this.incrementTabBy(-1);
}); });
if (this.$store.getters.ready) { if (store.ready) {
this.startModals(); this.startModals();
} }
}, },

View file

@ -16,6 +16,8 @@
<script> <script>
import { useIntersectionObserver } from "@vueuse/core"; import { useIntersectionObserver } from "@vueuse/core";
import { useSettingsStore } from "@/stores/settings.js";
import { mapState } from "pinia";
// Export main app // Export main app
export default { export default {
@ -28,8 +30,9 @@ export default {
}, },
computed: { computed: {
streamImgUri: function () { ...mapState(useSettingsStore, ["baseUri"]),
return `${this.$store.getters.baseUri}/camera/mjpeg_stream`; streamImgUri() {
return `${this.baseUri}/camera/mjpeg_stream`;
}, },
}, },

View file

@ -5,6 +5,9 @@
</template> </template>
<script> <script>
import { mapState } from "pinia";
import { useSettingsStore } from "@/stores/settings";
export default { export default {
name: "ProgressBar", name: "ProgressBar",
@ -13,6 +16,7 @@ export default {
emits: ["set-tab"], emits: ["set-tab"],
computed: { computed: {
...mapState(useSettingsStore, ["ready"]),
tooltipOptions: function () { tooltipOptions: function () {
var title = this.id.charAt(0).toUpperCase() + this.id.slice(1); var title = this.id.charAt(0).toUpperCase() + this.id.slice(1);
return `pos: right; title: ${title}; delay: 500`; return `pos: right; title: ${title}; delay: 500`;
@ -21,7 +25,7 @@ export default {
classObject: function () { classObject: function () {
return { return {
"tabicon-active": this.currentTab == this.id, "tabicon-active": this.currentTab == this.id,
"uk-disabled": this.requireConnection && !this.$store.getters.ready, "uk-disabled": this.requireConnection && !this.ready,
}; };
}, },
}, },

View file

@ -1,6 +1,6 @@
<template> <template>
<div <div
v-if="!(requireConnection && !$store.getters.ready)" v-if="!(requireConnection && !ready)"
:hidden="currentTab != tabID" :hidden="currentTab != tabID"
class="uk-width-expand uk-height-1-1" class="uk-width-expand uk-height-1-1"
> >
@ -9,6 +9,9 @@
</template> </template>
<script> <script>
import { mapState } from "pinia";
import { useSettingsStore } from "@/stores/settings";
export default { export default {
name: "TabContent", name: "TabContent",
@ -23,7 +26,9 @@ export default {
}, },
requireConnection: Boolean, requireConnection: Boolean,
}, },
computed: {}, computed: {
...mapState(useSettingsStore, ["ready"]),
},
methods: {}, methods: {},
}; };

View file

@ -14,6 +14,9 @@
</template> </template>
<script> <script>
import { mapState } from "pinia";
import { useSettingsStore } from "@/stores/settings.js";
export default { export default {
name: "TabIcon", name: "TabIcon",
@ -52,6 +55,7 @@ export default {
emits: ["set-tab"], emits: ["set-tab"],
computed: { computed: {
...mapState(useSettingsStore, ["ready"]),
computedTitle: function () { computedTitle: function () {
if (this.title !== undefined) { if (this.title !== undefined) {
return this.title; return this.title;
@ -74,7 +78,7 @@ export default {
classObject: function () { classObject: function () {
return { return {
"tabicon-active": this.currentTab == this.tabID, "tabicon-active": this.currentTab == this.tabID,
"uk-disabled": this.requireConnection && !this.$store.getters.ready, "uk-disabled": this.requireConnection && !this.ready,
}; };
}, },
}, },

View file

@ -257,7 +257,7 @@ export default {
* *
*/ */
async checkExistingTasks() { async checkExistingTasks() {
const ongoingTask = await this.getOngingAction(this.thing, this.action); const ongoingTask = await this.getOngoingAction(this.thing, this.action);
if (ongoingTask) { if (ongoingTask) {
// There is a started task // There is a started task
this.taskStarted = true; this.taskStarted = true;

View file

@ -29,7 +29,7 @@ export default {
computed: { computed: {
barWidthFromProgress: function () { barWidthFromProgress: function () {
var progress = this.progress <= 100 ? this.progress : 100; var progress = this.progress <= 100 ? this.progress : 100;
var styleString = `width: ${progress}%`; var styleString = progress != null ? `width: ${progress}%` : `width: 0%`;
return styleString; return styleString;
}, },
indeterminateProgressBar: function () { indeterminateProgressBar: function () {

View file

@ -67,11 +67,11 @@ export default {
computed: { computed: {
propertyDescription: function () { propertyDescription: function () {
try { const td = this.wotStore.thingDescriptions[this.thingName];
return this.thingDescription(this.thingName).properties[this.propertyName]; // Return `undefined` if the thing doesn't exist or has no properties
} catch { if (!td || !td.properties) return undefined;
return undefined; // JS returns `undefined` if this property name doesn't exist
} return td.properties[this.propertyName];
}, },
}, },

View file

@ -1,10 +1,12 @@
<template> <template>
<div class="uk-flex uk-flex-column uk-flex-center uk-height-1-1"> <div class="uk-flex uk-flex-column uk-flex-center uk-height-1-1">
<div v-if="$store.state.waiting" class="uk-align-center" uk-spinner="ratio: 3"></div> <div v-if="waiting" class="uk-align-center uk-text-center">
<div v-if="$store.state.waiting" class="uk-align-center">Loading...</div> <div uk-spinner="ratio: 3"></div>
<p>Loading...</p>
</div>
<span class="material-symbols-outlined uk-align-center error-icon">error_outline</span> <span class="material-symbols-outlined uk-align-center error-icon">error_outline</span>
<div v-if="$store.state.error" class="uk-align-center"> <div v-if="error" class="uk-align-center">
{{ $store.state.error }} {{ error }}
</div> </div>
<div class="uk-align-center"> <div class="uk-align-center">
<devTools class="uk-width-medium"></devTools> <devTools class="uk-width-medium"></devTools>
@ -14,6 +16,8 @@
<script> <script>
import devTools from "./tabContentComponents/aboutComponents/devTools.vue"; import devTools from "./tabContentComponents/aboutComponents/devTools.vue";
import { mapWritableState } from "pinia";
import { useSettingsStore } from "@/stores/settings.js";
// Export main app // Export main app
export default { export default {
@ -23,8 +27,8 @@ export default {
devTools, devTools,
}, },
data: function () { computed: {
return {}; ...mapWritableState(useSettingsStore, ["error", "waiting"]),
}, },
}; };
</script> </script>

View file

@ -22,6 +22,8 @@
<script> <script>
import stepTemplateWithStream from "../stepTemplateWithStream.vue"; import stepTemplateWithStream from "../stepTemplateWithStream.vue";
import cameraCalibrationSettings from "../../../tabContentComponents/settingsComponents/cameraSettingsComponents/cameraCalibrationSettings.vue"; import cameraCalibrationSettings from "../../../tabContentComponents/settingsComponents/cameraSettingsComponents/cameraCalibrationSettings.vue";
import { useSettingsStore } from "@/stores/settings.js";
import { mapState } from "pinia";
export default { export default {
name: "CameraMainCalibrationStep", name: "CameraMainCalibrationStep",
@ -34,8 +36,9 @@ export default {
emits: ["awaiting-user"], emits: ["awaiting-user"],
computed: { computed: {
...mapState(useSettingsStore, ["baseUri"]),
cameraUri: function () { cameraUri: function () {
return `${this.$store.getters.baseUri}/camera/`; return `${this.baseUri}/camera/`;
}, },
}, },

View file

@ -2,7 +2,7 @@
<div> <div>
<form class="uk-form-stacked" action="" method="GET" @submit="overrideAPIHost"> <form class="uk-form-stacked" action="" method="GET" @submit="overrideAPIHost">
<label class="uk-form-label">Override API origin</label> <label class="uk-form-label">Override API origin</label>
<input v-model="newOrigin" name="overrideOrigin" class="uk-input" type="text" /> <input v-model="overrideOrigin" name="overrideOrigin" class="uk-input" type="text" />
<label class="uk-form-label"> <label class="uk-form-label">
<input v-model="reloadWhenOverridingOrigin" class="uk-checkbox" type="checkbox" /> <input v-model="reloadWhenOverridingOrigin" class="uk-checkbox" type="checkbox" />
Reload web app with new origin Reload web app with new origin
@ -13,29 +13,26 @@
</template> </template>
<script> <script>
// Export main app import { mapWritableState } from "pinia";
import { useSettingsStore } from "@/stores/settings.js";
export default { export default {
name: "DevTools", name: "DevTools",
components: {}, data() {
data: function () {
return { return {
newOrigin: this.$store.state.overrideOrigin,
reloadWhenOverridingOrigin: true, reloadWhenOverridingOrigin: true,
}; };
}, },
methods: { computed: {
overrideAPIHost: function (event) { ...mapWritableState(useSettingsStore, ["overrideOrigin", "origin"]),
// Save the origin override, so that if we reload the web app, you can easily },
this.$store.commit("changeOverrideOrigin", this.newOrigin);
// If we have elected not to reload the interface, just update the origin methods: {
// in the store. Otherwise, the form's default action will do the job for us. overrideAPIHost(event) {
// TODO: preserve other query parameters when reloading
if (!this.reloadWhenOverridingOrigin) { if (!this.reloadWhenOverridingOrigin) {
this.$store.commit("changeOrigin", this.newOrigin); this.origin = this.overrideOrigin;
event.preventDefault(); event.preventDefault();
} }
}, },

View file

@ -1,16 +1,16 @@
<template> <template>
<div class="host-input"> <div class="host-input">
<div v-if="$store.state.available"> <div v-if="available">
<div> <div>
<div class="uk-margin-small-bottom"> <div class="uk-margin-small-bottom">
<b>Microscope Hostname:</b> <b>Microscope Hostname:</b>
<br /> <br />
{{ $store.state.microscopeHostname }} {{ microscopeHostname }}
</div> </div>
<div class="uk-margin-small-bottom"> <div class="uk-margin-small-bottom">
<b>API Origin:</b> <b>API Origin:</b>
<br /> <br />
{{ $store.state.origin }} {{ origin }}
</div> </div>
<action-button <action-button
v-if="illuminationType" v-if="illuminationType"
@ -56,14 +56,17 @@
<hr /> <hr />
</div> </div>
<div v-else-if="$store.state.waiting">Loading...</div> <div v-else-if="waiting">Loading...</div>
<div v-else-if="$store.state.error"><b>Error:</b> {{ $store.state.error }}</div> <div v-else-if="error"><b>Error:</b> {{ error }}</div>
<div v-else>No active connection</div> <div v-else>No active connection</div>
</div> </div>
</template> </template>
<script> <script>
import ActionButton from "../../labThingsComponents/actionButton.vue"; import ActionButton from "../../labThingsComponents/actionButton.vue";
import { mapState, mapWritableState } from "pinia";
import { useSettingsStore } from "@/stores/settings.js";
import { useWotStore } from "@/stores/wot.js";
export default { export default {
name: "StatusPane", name: "StatusPane",
@ -79,16 +82,20 @@ export default {
}, },
computed: { computed: {
...mapState(useSettingsStore, ["origin", "microscopeHostname", "available", "waiting"]),
...mapState(useWotStore, ["thingDescriptions"]),
...mapWritableState(useSettingsStore, ["error"]),
cameraType() { cameraType() {
// No need to check as the microscope won't start up without a camera defined // No need to check as the microscope won't start up without a camera defined
return this.thingDescription("camera").title; return this.thingDescriptions["camera"]?.title;
}, },
stageType() { stageType() {
return this.thingAvailable("stage") ? this.thingDescription("stage").title : undefined; return this.thingAvailable("stage") ? this.thingDescriptions["stage"]?.title : undefined;
}, },
illuminationType() { illuminationType() {
return this.thingAvailable("illumination") return this.thingAvailable("illumination")
? this.thingDescription("illumination").title ? this.thingDescriptions["illumination"]?.title
: undefined; : undefined;
}, },
}, },

View file

@ -202,7 +202,7 @@ export default {
}, },
async checkIfStartedExternally() { async checkIfStartedExternally() {
if (!this.taskStarted | !this.taskRunning) { if (!this.taskStarted | !this.taskRunning) {
const ongoingTask = await this.getOngingAction(this.thing, this.action); const ongoingTask = await this.getOngoingAction(this.thing, this.action);
if (ongoingTask) { if (ongoingTask) {
this.resetData(); this.resetData();
const taskUrl = ongoingTask.links.find((t) => t.rel == "self").href; const taskUrl = ongoingTask.links.find((t) => t.rel == "self").href;

View file

@ -79,6 +79,8 @@
<script> <script>
import { eventBus } from "@/eventBus.js"; import { eventBus } from "@/eventBus.js";
import { mapWritableState } from "pinia";
import { useSettingsStore } from "@/stores/settings.js";
export default { export default {
name: "StageControlButtons", name: "StageControlButtons",
@ -97,6 +99,11 @@ export default {
jogDistance: 600, jogDistance: 600,
jogTime: 300, jogTime: 300,
}), }),
computed: {
...mapWritableState(useSettingsStore, ["navigationInvert"]),
},
methods: { methods: {
/** /**
* Jog d-pad and focus buttons. * Jog d-pad and focus buttons.
@ -116,11 +123,10 @@ export default {
// pointer is. // pointer is.
pointerEvent.target.setPointerCapture(pointerEvent.pointerId); pointerEvent.target.setPointerCapture(pointerEvent.pointerId);
const navigationInvert = this.$store.state.navigationInvert;
let invokeJog = () => let invokeJog = () =>
this.invokeAction("stage", "jog", { this.invokeAction("stage", "jog", {
x: x * this.jogDistance * (navigationInvert.x ? -1 : 1), x: x * this.jogDistance * (this.navigationInvert.x ? -1 : 1),
y: y * this.jogDistance * (navigationInvert.y ? -1 : 1), y: y * this.jogDistance * (this.navigationInvert.y ? -1 : 1),
z: z * this.jogDistance, z: z * this.jogDistance,
}); });
invokeJog(); invokeJog();

View file

@ -92,6 +92,8 @@ import axios from "axios";
import PaginateLinks from "@/components/genericComponents/paginateLinks.vue"; import PaginateLinks from "@/components/genericComponents/paginateLinks.vue";
import EndpointButton from "../labThingsComponents/endpointButton.vue"; import EndpointButton from "../labThingsComponents/endpointButton.vue";
import { useIntersectionObserver } from "@vueuse/core"; import { useIntersectionObserver } from "@vueuse/core";
import { useSettingsStore } from "@/stores/settings.js";
import { mapState } from "pinia";
export default { export default {
name: "LoggingContent", name: "LoggingContent",
@ -114,6 +116,7 @@ export default {
}, },
computed: { computed: {
...mapState(useSettingsStore, ["baseUri"]),
filteredLevels: function () { filteredLevels: function () {
let cutoffIndex = this.allLevels.indexOf(this.filterLevel); let cutoffIndex = this.allLevels.indexOf(this.filterLevel);
return this.allLevels.slice(cutoffIndex, -1); return this.allLevels.slice(cutoffIndex, -1);
@ -130,10 +133,10 @@ export default {
return items; return items;
}, },
logURI: function () { logURI: function () {
return `${this.$store.getters.baseUri}/log/`; return `${this.baseUri}/log/`;
}, },
logFileURI: function () { logFileURI: function () {
return `${this.$store.getters.baseUri}/logfile/`; return `${this.baseUri}/logfile/`;
}, },
pagedItems: function () { pagedItems: function () {
let startIndex = (this.currentPage - 1) * this.maxitems; let startIndex = (this.currentPage - 1) * this.maxitems;

View file

@ -29,6 +29,9 @@
<script> <script>
import axios from "axios"; import axios from "axios";
import { useWotStore } from "@/stores/wot.js";
import { mapActions } from "pinia";
import { useSettingsStore } from "@/stores/settings.js";
export default { export default {
name: "PowerContent", name: "PowerContent",
@ -52,6 +55,8 @@ export default {
}, },
methods: { methods: {
...mapActions(useSettingsStore, ["resetState"]),
...mapActions(useWotStore, ["deleteAllThingDescriptions"]),
systemRequest: function (action) { systemRequest: function (action) {
let message = ""; let message = "";
if (action == "reboot") { if (action == "reboot") {
@ -61,10 +66,10 @@ export default {
} }
this.modalConfirm(message).then( this.modalConfirm(message).then(
() => { () => {
this.$store.commit("resetState");
this.$store.commit("wot/deleteAllThingDescriptions");
// Post and silence errors // Post and silence errors
axios.post(this.thingActionUrl("system", action)).catch(() => {}); axios.post(this.thingActionUrl("system", action)).catch(() => {});
this.resetState();
this.deleteAllThingDescriptions();
}, },
() => {}, () => {},
); );

View file

@ -82,6 +82,8 @@
import axios from "axios"; import axios from "axios";
import actionButton from "../../labThingsComponents/actionButton.vue"; import actionButton from "../../labThingsComponents/actionButton.vue";
import EndpointButton from "../../labThingsComponents/endpointButton.vue"; import EndpointButton from "../../labThingsComponents/endpointButton.vue";
import { mapState } from "pinia";
import { useSettingsStore } from "@/stores/settings.js";
// Export main app // Export main app
export default { export default {
@ -109,11 +111,12 @@ export default {
emits: ["viewer-requested", "update-requested"], emits: ["viewer-requested", "update-requested"],
computed: { computed: {
...mapState(useSettingsStore, ["baseUri"]),
downloadStitchFile() { downloadStitchFile() {
return `${this.$store.getters.baseUri}/smart_scan/get_stitch/${this.scanData.name}`; return `${this.baseUri}/smart_scan/get_stitch/${this.scanData.name}`;
}, },
thumbnailPath() { thumbnailPath() {
return `${this.$store.getters.baseUri}/smart_scan/scans/stitched_thumbnail.jpg?scan_name=${this.scanData.name}&modified=${this.scanData.modified}`; return `${this.baseUri}/smart_scan/scans/stitched_thumbnail.jpg?scan_name=${this.scanData.name}&modified=${this.scanData.modified}`;
}, },
}, },

View file

@ -67,10 +67,6 @@ export default {
type: Object, type: Object,
default: null, default: null,
}, },
baseUri: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {

View file

@ -41,18 +41,10 @@
</div> </div>
</nav> </nav>
<ScanViewerModal <ScanViewerModal ref="scanViewer" :selected-scan="selectedScan" :base-uri="baseUri" />
ref="scanViewer"
:selected-scan="selectedScan"
:base-uri="$store.getters.baseUri"
/>
<!-- Gallery --> <!-- Gallery -->
<div <div v-if="ready" class="uk-padding-remove-top" uk-lightbox="toggle: .lightbox-link">
v-if="$store.getters.ready"
class="uk-padding-remove-top"
uk-lightbox="toggle: .lightbox-link"
>
<!-- Gallery capture cards --> <!-- Gallery capture cards -->
<div class="gallery-grid uk-grid-match" uk-grid> <div class="gallery-grid uk-grid-match" uk-grid>
<div v-if="scansEmpty"> <div v-if="scansEmpty">
@ -86,6 +78,9 @@ import scanCard from "./scanListComponents/scanCard.vue";
import ScanViewerModal from "./scanListComponents/scanViewer.vue"; import ScanViewerModal from "./scanListComponents/scanViewer.vue";
import { eventBus } from "../../eventBus.js"; import { eventBus } from "../../eventBus.js";
import { useIntersectionObserver } from "@vueuse/core"; import { useIntersectionObserver } from "@vueuse/core";
import { useSettingsStore } from "@/stores/settings.js";
import { mapState, storeToRefs } from "pinia";
import { watch } from "vue";
// Export main app // Export main app
export default { export default {
@ -111,6 +106,7 @@ export default {
}, },
computed: { computed: {
...mapState(useSettingsStore, ["baseUri", "ready"]),
scansUri() { scansUri() {
return this.thingPropertyUrl("smart_scan", "scans"); return this.thingPropertyUrl("smart_scan", "scans");
}, },
@ -119,7 +115,7 @@ export default {
}, },
selectedScanDZI() { selectedScanDZI() {
if (this.selectedScan && this.selectedScan.dzi != "") { if (this.selectedScan && this.selectedScan.dzi != "") {
return `${this.$store.getters.baseUri}/data/smart_scan/${this.selectedScan.name}/images/${this.selectedScan.dzi}`; return `${this.baseUri}/data/smart_scan/${this.selectedScan.name}/images/${this.selectedScan.dzi}`;
} else { } else {
return null; return null;
} }
@ -134,6 +130,15 @@ export default {
}, },
async mounted() { async mounted() {
const store = useSettingsStore();
const { ready } = storeToRefs(store);
watch(ready, (isReady) => {
if (isReady) {
this.updateScans();
} else {
this.scans = [];
}
});
useIntersectionObserver( useIntersectionObserver(
this.$refs.galleryDisplay, this.$refs.galleryDisplay,
([{ isIntersecting }]) => { ([{ isIntersecting }]) => {
@ -155,24 +160,6 @@ export default {
}); });
}, },
created: function () {
// Watch for host 'ready', then update status
this.unwatchStoreFunction = this.$store.watch(
(state, getters) => {
return getters.ready;
},
(ready) => {
if (ready) {
// If the connection is now ready, update capture list
this.updateScans();
} else {
// If the connection is now disconnected, empty capture list
this.captures = {};
}
},
);
},
beforeUnmount() { beforeUnmount() {
// Remove global signal listener to perform a gallery refresh // Remove global signal listener to perform a gallery refresh
eventBus.off("globalUpdateScans", this.updateScans); eventBus.off("globalUpdateScans", this.updateScans);

View file

@ -25,6 +25,8 @@
import cameraCalibrationSettings from "./cameraSettingsComponents/cameraCalibrationSettings.vue"; import cameraCalibrationSettings from "./cameraSettingsComponents/cameraCalibrationSettings.vue";
import miniStreamDisplay from "../../genericComponents/miniStreamDisplay.vue"; import miniStreamDisplay from "../../genericComponents/miniStreamDisplay.vue";
import ServerSpecifiedPropertyControl from "../../labThingsComponents/serverSpecifiedPropertyControl.vue"; import ServerSpecifiedPropertyControl from "../../labThingsComponents/serverSpecifiedPropertyControl.vue";
import { mapState } from "pinia";
import { useSettingsStore } from "@/stores/settings.js";
// Export main app // Export main app
export default { export default {
@ -43,8 +45,9 @@ export default {
}, },
computed: { computed: {
cameraUri: function () { ...mapState(useSettingsStore, ["baseUri"]),
return `${this.$store.getters.baseUri}/camera/`; cameraUri() {
return `${this.baseUri}/camera/`;
}, },
}, },

View file

@ -19,6 +19,9 @@
</div> </div>
</template> </template>
<script> <script>
import { useSettingsStore } from "@/stores/settings.js";
import { mapWritableState } from "pinia";
// Export main app // Export main app
export default { export default {
name: "AppSettings", name: "AppSettings",
@ -28,14 +31,7 @@ export default {
}, },
computed: { computed: {
appTheme: { ...mapWritableState(useSettingsStore, ["appTheme"]),
get() {
return this.$store.state.appTheme;
},
set(value) {
this.$store.commit("changeAppTheme", value);
},
},
}, },
methods: { methods: {

View file

@ -12,6 +12,9 @@
</template> </template>
<script> <script>
import { mapWritableState } from "pinia";
import { useSettingsStore } from "@/stores/settings.js";
// Export main app // Export main app
export default { export default {
name: "StreamSettings", name: "StreamSettings",
@ -21,14 +24,7 @@ export default {
}, },
computed: { computed: {
disableStream: { ...mapWritableState(useSettingsStore, ["disableStream"]),
get() {
return this.$store.state.disableStream;
},
set(value) {
this.$store.commit("changeDisableStream", value);
},
},
}, },
}; };
</script> </script>

View file

@ -10,29 +10,27 @@
<div> <div>
<label class="uk-form-label" for="form-stacked-text">x</label> <label class="uk-form-label" for="form-stacked-text">x</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<input v-model="stepSize.x" class="uk-input uk-form-small" type="number" /> <input v-model="navigationStepSize.x" class="uk-input uk-form-small" type="number" />
</div> </div>
<label class="uk-margin-small-right"> <label class="uk-margin-small-right">
<input v-model="invert.x" class="uk-checkbox" type="checkbox" /> <input v-model="navigationInvert.x" class="uk-checkbox" type="checkbox" />
Invert x Invert x
</label> </label>
</div> </div>
<div> <div>
<label class="uk-form-label" for="form-stacked-text">y</label> <label class="uk-form-label" for="form-stacked-text">y</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<input v-model="stepSize.y" class="uk-input uk-form-small" type="number" /> <input v-model="navigationStepSize.y" class="uk-input uk-form-small" type="number" />
</div> </div>
<label class="uk-margin-small-right"> <label class="uk-margin-small-right">
<input v-model="invert.y" class="uk-checkbox" type="checkbox" /> <input v-model="navigationInvert.y" class="uk-checkbox" type="checkbox" />
Invert y Invert y
</label> </label>
</div> </div>
<div> <div>
<label class="uk-form-label" for="form-stacked-text">z</label> <label class="uk-form-label" for="form-stacked-text">z</label>
<div class="uk-form-controls"> <div class="uk-form-controls">
<input v-model="stepSize.z" class="uk-input uk-form-small" type="number" /> <input v-model="navigationStepSize.z" class="uk-input uk-form-small" type="number" />
</div> </div>
</div> </div>
</div> </div>
@ -40,35 +38,16 @@
</template> </template>
<script> <script>
import { storeToRefs } from "pinia";
import { useSettingsStore } from "@/stores/settings.js";
export default { export default {
name: "StageControlSettings", name: "StageControlSettings",
computed: { setup() {
// Note that as stepSize and invert are mutated (i.e. we change stepSize.x not stepSize) const store = useSettingsStore();
// rather than directly set we cannot use get() and set() computed to interact with the const { navigationStepSize, navigationInvert } = storeToRefs(store);
// store as changed won't be detected by set(). Instead use a deep watcher to return { navigationStepSize, navigationInvert };
// update the store (see ``watch:`` below)
stepSize() {
return this.$store.state.navigationStepSize;
},
invert() {
return this.$store.state.navigationInvert;
},
},
watch: {
stepSize: {
deep: true,
handler(newVal) {
this.$store.commit("changeNavigationStepSize", newVal);
},
},
invert: {
deep: true,
handler(newVal) {
this.$store.commit("changeNavigationInvert", newVal);
},
},
}, },
}; };
</script> </script>

View file

@ -27,6 +27,7 @@
</div> </div>
<div class="uk-margin"> <div class="uk-margin">
<propertyControl <propertyControl
v-if="thingAvailable('smart_scan')"
thing-name="smart_scan" thing-name="smart_scan"
property-name="stitch_tiff" property-name="stitch_tiff"
label="When Stitching, Produce a Pyramidal TIFF" label="When Stitching, Produce a Pyramidal TIFF"
@ -55,6 +56,7 @@ export default {
workflowName: undefined, workflowName: undefined,
workflowSettings: [], workflowSettings: [],
workflowOptions: [], workflowOptions: [],
workflowReady: false,
}; };
}, },
@ -95,7 +97,7 @@ export default {
} }
if (this.workflowName) { if (this.workflowName) {
this.ready = await this.readThingProperty(this.workflowName, "ready", true); this.workflowReady = await this.readThingProperty(this.workflowName, "ready", true);
this.workflowSettings = this.workflowSettings =
(await this.getThingEndpoint(this.workflowName, "settings_ui")) || []; (await this.getThingEndpoint(this.workflowName, "settings_ui")) || [];
} }

View file

@ -61,7 +61,8 @@ import actionTab from "./actionTab.vue";
import slideScanControls from "./slideScanComponents/slideScanControls.vue"; import slideScanControls from "./slideScanComponents/slideScanControls.vue";
import streamDisplay from "./streamContent.vue"; import streamDisplay from "./streamContent.vue";
import ActionButton from "../labThingsComponents/actionButton.vue"; import ActionButton from "../labThingsComponents/actionButton.vue";
import { mapState } from "pinia";
import { useSettingsStore } from "@/stores/settings.js";
export default { export default {
name: "SlideScanContent", name: "SlideScanContent",
@ -84,6 +85,7 @@ export default {
}, },
computed: { computed: {
...mapState(useSettingsStore, ["baseUri"]),
scanning() { scanning() {
return this.taskId && this.taskUrl; return this.taskId && this.taskUrl;
}, },
@ -138,7 +140,7 @@ export default {
// while the scan is running // while the scan is running
let mtime = await this.readThingProperty("smart_scan", "latest_preview_stitch_time", true); let mtime = await this.readThingProperty("smart_scan", "latest_preview_stitch_time", true);
if (mtime !== null) { if (mtime !== null) {
this.lastStitchedImage = `${this.$store.getters.baseUri}/smart_scan/latest_preview_stitch.jpg?t=${mtime}`; this.lastStitchedImage = `${this.baseUri}/smart_scan/latest_preview_stitch.jpg?t=${mtime}`;
} }
this.lastScanName = await this.readThingProperty("smart_scan", "latest_scan_name", true); this.lastScanName = await this.readThingProperty("smart_scan", "latest_scan_name", true);

View file

@ -15,14 +15,11 @@
/> />
<div v-if="!streamEnabled" class="uk-height-1-1"> <div v-if="!streamEnabled" class="uk-height-1-1">
<div v-if="$store.state.waiting" class="uk-position-center"> <div v-if="waiting" class="uk-position-center">
<div uk-spinner="ratio: 4.5"></div> <div uk-spinner="ratio: 4.5"></div>
</div> </div>
<div <div v-else-if="disableStream" class="uk-position-center position-relative text-center">
v-else-if="$store.state.disableStream"
class="uk-position-center position-relative text-center"
>
Stream preview disabled Stream preview disabled
</div> </div>
@ -36,11 +33,20 @@
<script> <script>
import { eventBus } from "../../eventBus.js"; import { eventBus } from "../../eventBus.js";
import { useIntersectionObserver } from "@vueuse/core"; import { useIntersectionObserver } from "@vueuse/core";
import { useSettingsStore } from "@/stores/settings.js";
import { mapState } from "pinia";
// Export main app // Export main app
export default { export default {
name: "StreamDisplay", name: "StreamDisplay",
setup() {
const store = useSettingsStore();
return {
store,
};
},
data: function () { data: function () {
return { return {
isVisible: false, isVisible: false,
@ -51,15 +57,16 @@ export default {
}, },
computed: { computed: {
...mapState(useSettingsStore, ["ready", "baseUri", "disableStream", "waiting"]),
streamEnabled: function () { streamEnabled: function () {
return this.$store.getters.ready && !this.$store.state.disableStream; return this.ready && !this.disableStream;
}, },
thisStreamOpen: function () { thisStreamOpen: function () {
// Only a single MJPEG connection should be open at a time // Only a single MJPEG connection should be open at a time
return !(this.displaySize[0] == 0) && !(this.displaySize[1] == 0); return !(this.displaySize[0] == 0) && !(this.displaySize[1] == 0);
}, },
streamImgUri: function () { streamImgUri: function () {
return `${this.$store.getters.baseUri}/camera/mjpeg_stream`; return `${this.baseUri}/camera/mjpeg_stream`;
}, },
}, },
@ -101,7 +108,7 @@ export default {
// Disconnect the size observer // Disconnect the size observer
this.sizeObserver.disconnect(); this.sizeObserver.disconnect();
// Remove from the array of active streams // Remove from the array of active streams
this.$store.commit("removeStream", this._uid); this.store.removeStream(this.$.uid);
}, },
methods: { methods: {
@ -154,12 +161,12 @@ export default {
// Handle closed stream // Handle closed stream
if (this.displaySize[0] == 0 && this.displaySize[1] == 0) { if (this.displaySize[0] == 0 && this.displaySize[1] == 0) {
// If this stream was previously active // If this stream was previously active
if (this.$store.state.activeStreams[this._uid] == true) { if (this.store.activeStreams[this.$.uid] == true) {
this.$store.commit("removeStream", this._uid); this.store.removeStream(this.$.uid);
} }
// If resized to anything other than zero // If resized to anything other than zero
} else { } else {
this.$store.commit("addStream", this._uid); this.store.addStream(this.$.uid);
} }
}, },

View file

@ -1,6 +1,7 @@
import { createApp } from "vue"; import { createApp } from "vue";
import { createPinia } from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import App from "./App.vue"; import App from "./App.vue";
import store from "./store";
import UIkit from "uikit"; import UIkit from "uikit";
// Import MD icons // Import MD icons
@ -21,14 +22,14 @@ UIkit.mixin(
// Create Vue app // Create Vue app
const app = createApp(App); const app = createApp(App);
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
// Use visibility observer // Use Pinia
//app.use(VueObserveVisibility); app.use(pinia);
// Use global mixins // Use global mixins
app.mixin(modalMixin); app.mixin(modalMixin);
app.mixin(labThingsMixins); app.mixin(labThingsMixins);
// Use Vuex store
app.use(store);
app.mount("#app"); app.mount("#app");

View file

@ -5,8 +5,10 @@
* This mixin is registered globally in `main.js` using. Do not * This mixin is registered globally in `main.js` using. Do not
* manually import it in components. * manually import it in components.
*/ */
import axios from "axios"; import axios from "axios";
import { useWotStore } from "@/stores/wot.js";
import { useSettingsStore } from "@/stores/settings.js";
import { mapState, mapStores } from "pinia";
export default { export default {
data() { data() {
@ -15,32 +17,35 @@ export default {
}; };
}, },
computed: {
...mapState(useSettingsStore, ["baseUri"]),
...mapStores(useWotStore),
},
methods: { methods: {
thingDescription(thing) { thingDescription(thing) {
return this.$store.getters["wot/thingDescription"](thing); return this.wotStore.thingDescriptions[thing];
}, },
thingList() { thingList() {
return this.$store.getters["wot/thingList"]; return this.wotStore.thingList();
}, },
thingAvailable(thing) { thingAvailable(thing) {
return this.$store.getters["wot/thingAvailable"](thing); return this.wotStore.thingAvailable(thing);
}, },
thingPropertyUrl(thing, property, allowUndefined = false) { thingPropertyUrl(thing, property, allowUndefined = false) {
return this.$store.getters["wot/thingPropertyUrl"]( return this.wotStore.thingPropertyUrl(thing, property, "readproperty", allowUndefined);
thing, },
property, thingActionUrl(thing, action, allowUndefined = false) {
"readproperty", return this.wotStore.thingActionUrl(thing, action, "invokeaction", allowUndefined);
allowUndefined,
);
}, },
thingActionAvailable(thing, action) { thingActionAvailable(thing, action) {
return this.$store.getters["wot/thingAffordanceAvailable"](thing, "actions", action); return this.wotStore.thingAffordanceAvailable(thing, "actions", action);
}, },
thingPropertyAvailable(thing, property) { thingPropertyAvailable(thing, property) {
return this.$store.getters["wot/thingAffordanceAvailable"](thing, "properties", property); return this.wotStore.thingAffordanceAvailable(thing, "properties", property);
}, },
async readThingProperty(thing, property, silenceErrors = false) { async readThingProperty(thing, property, silenceErrors = false) {
let url = this.$store.getters["wot/thingPropertyUrl"](thing, property, "readproperty", false); let url = this.wotStore.thingPropertyUrl(thing, property, "readproperty", false);
try { try {
let response = await axios.get(url); let response = await axios.get(url);
return response.data; return response.data;
@ -50,12 +55,7 @@ export default {
} }
}, },
async writeThingProperty(thing, property, value) { async writeThingProperty(thing, property, value) {
let url = this.$store.getters["wot/thingPropertyUrl"]( let url = this.wotStore.thingPropertyUrl(thing, property, "writeproperty", false);
thing,
property,
"writeproperty",
false,
);
// `false` and 0 fail because axios turns it to "" // `false` and 0 fail because axios turns it to ""
// Other values should not be stringified or pydantic // Other values should not be stringified or pydantic
// can't parse them. // can't parse them.
@ -88,7 +88,7 @@ export default {
let response; let response;
let finalMethodCalled = false; let finalMethodCalled = false;
try { try {
response = await axios.get(taskUrl, { baseURL: this.$store.getters.baseUri }); response = await axios.get(taskUrl, { baseURL: this.baseUri });
const result = response.data.status; const result = response.data.status;
if (result === "running" || result === "pending") { if (result === "running" || result === "pending") {
@ -115,7 +115,7 @@ export default {
} }
}, },
terminateAction(taskUrl) { terminateAction(taskUrl) {
axios.delete(taskUrl, { baseURL: this.$store.getters.baseUri }); axios.delete(taskUrl, { baseURL: this.baseUri });
}, },
async findOngoingActions(thing, action) { async findOngoingActions(thing, action) {
let url = this.thingActionUrl(thing, action); let url = this.thingActionUrl(thing, action);
@ -131,7 +131,7 @@ export default {
* *
* Returns null if there is no ongoing action * Returns null if there is no ongoing action
*/ */
async getOngingAction(thing, action) { async getOngoingAction(thing, action) {
let response = await this.findOngoingActions(thing, action); let response = await this.findOngoingActions(thing, action);
// Exit if response is null, due to an error. // Exit if response is null, due to an error.
if (response == null) return null; if (response == null) return null;
@ -140,17 +140,9 @@ export default {
// ?? Is the "Nullish Coalescing-Operator" to turn undefined into null. // ?? Is the "Nullish Coalescing-Operator" to turn undefined into null.
return response.data.find((t) => ["pending", "running"].includes(t.status)) ?? null; return response.data.find((t) => ["pending", "running"].includes(t.status)) ?? null;
}, },
thingActionUrl(thing, action, allowUndefined = false) {
let url = this.$store.getters["wot/thingActionUrl"](
thing,
action,
"invokeaction",
allowUndefined,
);
return url;
},
async getThingEndpoint(thing, endpoint) { async getThingEndpoint(thing, endpoint) {
let url = `${this.$store.getters.baseUri}/${thing}/${endpoint}`; let url = `${this.baseUri}/${thing}/${endpoint}`;
try { try {
const response = await axios.get(url); const response = await axios.get(url);
return response.data; return response.data;

View file

@ -8,6 +8,7 @@
import UIkit from "uikit"; import UIkit from "uikit";
import { eventBus } from "@/eventBus"; import { eventBus } from "@/eventBus";
import { useSettingsStore } from "@/stores/settings.js";
export default { export default {
methods: { methods: {
@ -55,9 +56,10 @@ export default {
); );
}, },
modalError: function (error) { modalError(set_error) {
var errormsg = this.getErrorMessage(error); const store = useSettingsStore();
this.$store.commit("setErrorMessage", errormsg); var errormsg = this.getErrorMessage(set_error);
store.error = errormsg;
UIkit.notification({ UIkit.notification({
message: `${errormsg}`, message: `${errormsg}`,
status: "danger", status: "danger",
@ -78,30 +80,30 @@ export default {
return String(data); return String(data);
} }
}, },
getErrorData: function (error) { getErrorData: function (data_error) {
// If a response was obtained, extract the most specific message // If a response was obtained, extract the most specific message
if (error.response) { if (data_error.response) {
// If the response is a nicely formatted JSON response from the server // If the response is a nicely formatted JSON response from the server
if (error.response.data.message) { if (data_error.response.data.message) {
return error.response.data.message; return data_error.response.data.message;
} }
if (error.response.data.detail) { if (data_error.response.data.detail) {
try { try {
return error.response.data.detail[0].msg; return data_error.response.data.detail[0].msg;
} catch { } catch {
return error.response.data.detail; return data_error.response.data.detail;
} }
} }
// If the response is just some generic error response // If the response is just some generic error response
if (error.response.data) { if (data_error.response.data) {
return error.response.data; return data_error.response.data;
} }
return error.response; return data_error.response;
} }
// If we have an error object with a message, use that // If we have an error object with a message, use that
if (error.message) return error.message; if (data_error.message) return data_error.message;
// At this point just formatting the whole error object is the best we can do. // At this point just formatting the whole error object is the best we can do.
return error; return data_error;
}, },
showModalElement: function (element) { showModalElement: function (element) {

View file

@ -1,169 +0,0 @@
import { createStore } from "vuex";
import wotStoreModule from "./wot-client";
function getOriginFromLocation() {
// This will default to the same origin that's serving
// the web app - but can be overridden by the URL.
// See also devTools.vue which can change the origin.
let url = new URL(window.location.href);
let origin = url.searchParams.get("overrideOrigin");
if (origin) {
return origin;
} else {
return url.origin;
}
}
/**
* Converts a Vuex state key (e.g. "appTheme") into a corresponding
* Vuex mutation name (e.g. "changeAppTheme") using the `change<Key>`
* convention.
*
* @param {string} key - The Vuex state key to convert.
* @returns {string} - The formatted mutation name.
*/
function keyToMutationName(key) {
return `change${key.charAt(0).toUpperCase() + key.slice(1)}`;
}
/**
* Converts a Vuex mutation name (e.g. "changeAppTheme") back into
* the corresponding state key (e.g. "appTheme") using the
* `change<Key>` convention.
*
* @param {string} mutationName - The Vuex mutation name to reverse.
* @returns {string|null} - The derived state key, or null if the
* mutation name doesn't match the `change<Key>` convention.
*/
function mutationToKey(mutationName) {
const prefix = "change";
if (!mutationName.startsWith(prefix)) {
return null; // Not a mutation we care about
}
const key = mutationName.slice(prefix.length);
return key.charAt(0).toLowerCase() + key.slice(1);
}
const LOCALSTORAGE_KEYS = [
"appTheme",
"disableStream",
"overrideOrigin",
"navigationStepSize",
"navigationInvert",
];
export default createStore({
modules: {
wot: wotStoreModule,
},
state: {
origin: getOriginFromLocation(),
available: false,
waiting: false,
error: "",
trackWindow: true,
activeStreams: {},
microscopeHostname: "",
// Persistent items:
// The app theme (e.g. light/dark)
appTheme: "system",
disableStream: false,
// The origin to use if overriding with dev tools
overrideOrigin: "http://microscope.local:5000",
// The step sizes for navigation via control pane/keys presses
navigationStepSize: {
x: 200,
y: 200,
z: 50,
},
// The axis inversion for navigation via control pane/keys presses
navigationInvert: {
x: false,
y: false,
z: false,
},
},
mutations: {
changeOrigin(state, origin) {
state.origin = origin;
},
changeWaiting(state, waiting) {
state.waiting = waiting;
},
changeDisableStream(state, disabled) {
state.disableStream = disabled;
},
changeTrackWindow(state, enabled) {
state.trackWindow = enabled;
},
changeAppTheme(state, theme) {
state.appTheme = theme;
},
resetState(state) {
state.waiting = false;
state.available = false;
state.error = null;
},
setConnected(state) {
state.waiting = false;
state.available = true;
},
setErrorMessage(state, msg) {
state.error = msg;
},
addStream(state, id) {
state.activeStreams[id] = true;
},
removeStream(state, id) {
state.activeStreams[id] = false;
},
changeMicroscopeHostname(state, value) {
state.microscopeHostname = value;
},
changeOverrideOrigin(state, value) {
state.overrideOrigin = value;
},
changeNavigationStepSize(state, value) {
state.navigationStepSize = value;
},
changeNavigationInvert(state, value) {
state.navigationInvert = value;
},
},
actions: {},
getters: {
baseUri: (state) => state.origin,
ready: (state) => state.available,
},
plugins: [
(store) => {
// Load initial state from localStorage
LOCALSTORAGE_KEYS.forEach((key) => {
const saved = localStorage.getItem(key);
if (saved !== null) {
try {
const parsed = JSON.parse(saved);
const mutationName = keyToMutationName(key);
store.commit(mutationName, parsed);
} catch (e) {
console.warn(`Failed to parse localStorage key "${key}":`, e);
localStorage.removeItem(key);
}
}
});
// Subscribe to mutations
store.subscribe((mutation, state) => {
const key = mutationToKey(mutation.type);
// If the mutation is chacning a local storage key then update localStorage
if (key && LOCALSTORAGE_KEYS.includes(key)) {
localStorage.setItem(key, JSON.stringify(state[key]));
}
});
},
],
});

View file

@ -0,0 +1,110 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
function getOriginFromLocation() {
// This will default to the same origin that's serving
// the web app - but can be overridden by the URL.
// See also devTools.vue which can change the origin.
let url = new URL(window.location.href);
let origin = url.searchParams.get("overrideOrigin");
if (origin) {
return origin;
} else {
return url.origin;
}
}
// Define Pinia store
export const useSettingsStore = defineStore(
"settings",
() => {
// State
const origin = ref(getOriginFromLocation());
const available = ref(false);
const waiting = ref(false);
const error = ref("");
const trackWindow = ref(true);
const activeStreams = ref({});
const microscopeHostname = ref("");
// Persistent items:
// The app theme (e.g. light/dark)
const appTheme = ref("system");
const disableStream = ref(false);
// The origin to use if overriding with dev tools
const overrideOrigin = ref("http://microscope.local:5000");
// The step sizes for navigation via control pane/keys presses
const navigationStepSize = ref({
x: 200,
y: 200,
z: 50,
});
// The axis inversion for navigation via control pane/keys presses
const navigationInvert = ref({
x: false,
y: false,
z: false,
});
// Actions
function resetState() {
waiting.value = false;
available.value = false;
error.value = ""; // TODO: verify that "" is needed to match initial state
}
function setConnected() {
waiting.value = false;
available.value = true;
}
function addStream(id) {
activeStreams.value[id] = true;
}
function removeStream(id) {
activeStreams.value[id] = false;
}
// Getters
const baseUri = computed(() => origin.value);
const ready = computed(() => available.value);
// Export
return {
//State
origin,
available,
waiting,
error,
trackWindow,
activeStreams,
microscopeHostname,
appTheme,
disableStream,
overrideOrigin,
navigationStepSize,
navigationInvert,
//Getters
baseUri,
ready,
//Actions
resetState,
setConnected,
addStream,
removeStream,
};
},
{
// PiniaPluginPersistedState will now automatically persist ONLY these specific refs to localStorage
persist: {
pick: [
"appTheme",
"overrideOrigin",
"disableStream",
"navigationStepSize",
"navigationInvert",
],
},
},
);

127
webapp/src/stores/wot.js Normal file
View file

@ -0,0 +1,127 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import axios from "axios";
export const useWotStore = defineStore(
"wot",
() => {
// State
const thingDescriptions = ref({});
const servient = ref(null);
const helpers = ref(null);
// Actions
function addThingDescription(thingName, thingDescription) {
thingDescriptions.value[thingName] = thingDescription;
}
function removeThingDescription(thingName) {
delete thingDescriptions.value[thingName];
}
function removeAllThingDescriptions() {
thingDescriptions.value = {};
}
async function fetchThingDescription(uri, name = null) {
// Fetch the thing description from the given URI and consume it
// NB this should only be called once, or we'll duplicate effort.
// Deduplication should be done elsewhere.
const response = await axios.get(uri);
const td = response.data;
const thingName = name || uri.replace(/\/$/, "").split("/").pop();
addThingDescription(thingName, td);
}
async function fetchThingDescriptions(uri) {
// Fetch thing descriptions from the given URI
const response = await axios.get(uri);
if (response.status !== 200) throw "Could not retrieve thing descriptions";
for (const k in response.data) {
let thingName = k.replace(/\/$/, "").replace(/^\//, "");
addThingDescription(thingName, response.data[k]);
}
}
const thingList = computed(() => Object.keys(thingDescriptions.value));
const thingAvailable = computed(() => (thingName) => {
return thingName in thingDescriptions.value;
});
const thingAffordanceAvailable = computed(() => (thing, affordanceType, affordance) => {
const td = thingDescriptions.value[thing];
if (!td || !td[affordanceType]) return false;
return affordance in td[affordanceType];
});
const thingFormUrl = computed(
() =>
(thing, affordanceType, affordance, op, allowUndefined = true) => {
// Find the URL for a particular operation
const td = thingDescriptions.value[thing];
if (!td) {
if (allowUndefined) return undefined;
throw `Could not find form for ${affordanceType} ${thing}/${affordance} with op ${op}`;
}
const affordances = td[affordanceType];
if (!affordances || !(affordance in affordances)) {
if (allowUndefined) return undefined;
throw `Could not find form for ${affordanceType} ${thing}/${affordance} with op ${op}`;
}
let href = findFormHref(affordances[affordance], op);
if (href === undefined) {
if (allowUndefined) return undefined;
throw `Could not find form for ${affordanceType} ${thing}/${affordance} with op ${op}`;
}
// If we've found an href, prepend the `base` URL if appropriate
if (href.startsWith("http")) return href;
if ("base" in td) {
let base = td.base;
if (href.startsWith("/")) href = href.slice(1);
if (!base.endsWith("/")) base += "/";
return base + href;
}
return href;
},
);
const thingPropertyUrl = computed(() => (thing, property, op, allowUndefined) => {
// Find the URL for a particular property
return thingFormUrl.value(thing, "properties", property, op, allowUndefined);
});
const thingActionUrl = computed(() => (thing, action, op, allowUndefined) => {
// Find the URL for a particular action
return thingFormUrl.value(thing, "actions", action, op, allowUndefined);
});
return {
thingDescriptions,
servient,
helpers, // State
thingList,
thingAvailable, // Helpers
thingFormUrl,
thingPropertyUrl,
thingActionUrl,
fetchThingDescription,
fetchThingDescriptions, // Actions
addThingDescription,
removeThingDescription,
removeAllThingDescriptions,
thingAffordanceAvailable,
};
},
{
persist: false,
},
);
export function findFormHref(affordance, op) {
// Find the form in the affordance that matches the given operation type
if (affordance === undefined) return undefined;
let forms = affordance.forms;
let matchingForm = forms.find((f) => f.op == op || f.op.includes(op));
if (matchingForm === undefined) return undefined;
return matchingForm.href;
}

View file

@ -1,121 +0,0 @@
import axios from "axios";
export const wotStoreModule = {
namespaced: true,
state: () => ({
thingDescriptions: {},
servient: null,
helpers: null,
}),
mutations: {
addThingDescription(state, { thingName, thingDescription }) {
state.thingDescriptions[thingName] = thingDescription;
},
removeThingDescription(state, thingName) {
delete state.thingDescriptions[thingName];
},
removeAllThingDescriptions(state) {
state.thingDescriptions = {};
},
},
actions: {
async start() {
// Set up thing client - not currently used.
},
async fetchThingDescription({ commit }, { uri, name = null }) {
// Fetch the thing description from the given URI and consume it
// NB this should only be called once, or we'll duplicate effort.
// Deduplication should be done elsewhere.
let response = await axios.get(uri);
let td = response.data;
let thing_name = name || uri.replace(/\/$/, "").split("/").pop();
commit("addThingDescription", {
thingName: thing_name,
thingDescription: td,
});
},
async fetchThingDescriptions({ commit }, uri) {
// Fetch thing descriptions from the given URI
let response = await axios.get(uri);
if (response.status !== 200) throw "Could not retrieve thing descriptions";
for (const k in response.data) {
let thing_name = k.replace(/\/$/, "").replace(/^\//, "");
commit("addThingDescription", {
thingName: thing_name,
thingDescription: response.data[k],
});
}
},
},
getters: {
thingDescriptions: (state) => {
return state.thingDescriptions;
},
thingList: (state) => {
return Object.keys(state.thingDescriptions);
},
thingDescription: (state) => (thingName) => {
return state.thingDescriptions[thingName];
},
thingAvailable: (state) => (thingName) => {
return thingName in state.thingDescriptions;
},
thingAffordanceAvailable: (state) => (thing, affordanceType, affordance) => {
let td = state.thingDescriptions[thing];
if (!td) {
return false;
}
return affordance in td[affordanceType];
},
thingFormUrl:
(state) =>
(thing, affordanceType, affordance, op, allowUndefined = true) => {
// Find the URL for a particular operation
let td = state.thingDescriptions[thing];
if (!td) {
if (allowUndefined) return undefined;
throw `Could not find form for ${affordanceType} ${thing}/${affordance} with op ${op}`;
}
let affordances = td[affordanceType];
if (!affordances || !(affordance in affordances)) {
if (allowUndefined) return undefined;
throw `Could not find form for ${affordanceType} ${thing}/${affordance} with op ${op}`;
}
let href = findFormHref(affordances[affordance], op);
if (href === undefined) {
if (allowUndefined) return undefined;
throw `Could not find form for ${affordanceType} ${thing}/${affordance} with op ${op}`;
}
// If we've found an href, prepend the `base` URL if appropriate
if (href.startsWith("http")) return href;
if ("base" in td) {
let base = td.base;
if (href.startsWith("/")) href = href.slice(1);
if (!base.endsWith("/")) base += "/";
return base + href;
}
return href;
},
thingPropertyUrl: (_state, getters) => (thing, property, op, allowUndefined) => {
// Find the URL for a particular property
return getters.thingFormUrl(thing, "properties", property, op, allowUndefined);
},
thingActionUrl: (_state, getters) => (thing, action, op, allowUndefined) => {
// Find the URL for a particular action
return getters.thingFormUrl(thing, "actions", action, op, allowUndefined);
},
},
};
export function findFormHref(affordance, op) {
// Find the form in the affordance that matches the given operation type
if (affordance === undefined) return undefined;
let forms = affordance.forms;
let matchingForm = forms.find((f) => f.op == op || f.op.includes(op));
if (matchingForm === undefined) return undefined;
return matchingForm.href;
}
export default wotStoreModule;