Merge branch 'action-tab-2' into 'v3'

Split up slide scan tab and create a generic action tab (attempt 2)

See merge request openflexure/openflexure-microscope-server!536
This commit is contained in:
Joe Knapper 2026-03-23 10:17:18 +00:00
commit 65ac551688
5 changed files with 465 additions and 205 deletions

View file

@ -50,6 +50,18 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
// forceId and forceUrl can be used if it is known that when this button is mounted
// the action should be running.
forceId: {
type: [String, null],
required: false,
default: null,
},
forceUrl: {
type: [String, null],
required: false,
default: null,
},
submitData: { submitData: {
type: [Object, Array], type: [Object, Array],
required: false, required: false,
@ -197,17 +209,22 @@ export default {
}, },
); );
// Check for already running tasks if (this.forceId && this.forceUrl) {
if (this.taskStarted != true) { this.taskStarted = true;
this.checkExistingTasks(); this.startPollingTask(this.forceId, this.forceUrl);
} } else {
// A global signal listener to perform the action // Check for already running tasks
if (this.submitOnEvent) { if (this.taskStarted != true) {
eventBus.on(this.submitOnEvent, () => { this.checkExistingTasks();
if (this.isDisabled) return; }
// Bootstrap task if button is not disabled. // A global signal listener to perform the action
this.bootstrapTask(); if (this.submitOnEvent) {
}); eventBus.on(this.submitOnEvent, () => {
if (this.isDisabled) return;
// Bootstrap task if button is not disabled.
this.bootstrapTask();
});
}
} }
}, },
@ -240,18 +257,13 @@ export default {
* *
*/ */
async checkExistingTasks() { async checkExistingTasks() {
let response = await this.findOngoingActions(this.thing, this.action); const ongoingTask = await this.getOngingAction(this.thing, this.action);
// Exit if response is null, due to an error.
if (response == null) return;
// Check for a task that is ongoing.
// We can't handle multiple tasks ongoing, so this picks the first.
const ongoingTask = response.data.find((t) => ["pending", "running"].includes(t.status));
if (ongoingTask) { if (ongoingTask) {
// There is a started task // There is a started task
this.taskStarted = true; this.taskStarted = true;
this.$emit("taskStarted");
// Find its URL // Find its URL
const taskUrl = ongoingTask.links.find((t) => t.rel == "self").href; const taskUrl = ongoingTask.links.find((t) => t.rel == "self").href;
this.$emit("taskStarted", ongoingTask.id, taskUrl);
this.startPollingTask(ongoingTask.id, taskUrl); this.startPollingTask(ongoingTask.id, taskUrl);
} }
}, },
@ -275,7 +287,6 @@ export default {
this.$emit("submit", this.submitData); this.$emit("submit", this.submitData);
// Send a request to start a task // Send a request to start a task
this.taskStarted = true; this.taskStarted = true;
this.$emit("taskStarted");
let response; let response;
try { try {
response = await this.invokeAction( response = await this.invokeAction(
@ -289,6 +300,9 @@ export default {
this.onTaskEnd(); this.onTaskEnd();
return; return;
} }
// Wait until after the task is acknowledged before confirming to parent
// component that the task is started.
this.$emit("taskStarted", response.data.id, response.data.href);
if (this.modalProgress) { if (this.modalProgress) {
this.$refs.statusModal.show(); this.$refs.statusModal.show();
} }

View file

@ -0,0 +1,224 @@
<!-- A tab that changes its sidebar when an action is started.
## Overview
This is a generic component used to build tabs that need to switch view when an action is started,
such as the Slide Scan tab.
The task runs in 3 modes:
* **Unstarted** - No task has been started.
* **Running** - The task is running.
* **Completed** - The task has completed.
## HTML Slots and Template
The template has 3 slots.
* The main slot is the main view on the right hand side of the tab. To display the microscope stream
use `<streamDisplay />` here
* **controls** - A named slot for the controls that are shown in the sidebar in **Unstarted** mode.
* **task-info** - A slot for any information or controls to show at the bottom of the sidebar in
**Running** or **Completed** mode.
In **Running** or **Completed** modes the sidebar automatically shows:
* **A tile** if `taskInfoTitle` prop is set.
* **A mini stream** if `taskInfoStream` prop is `true`
* **A log view**
* **A cancel button** or a **Close** button depending on if the mode is **Running** or **Completed**
## Changing mode
To notify the tab that the action running the `taskId` and `taskUrl` props should be set. These values
are the data send from the `taskStarted` event of `actionButton`.
**Note:** This component will check whether the task has been started externally (for example, by
another window). A `actionStartedExternally` event with `taskId` and `taskUrl` data will be emitted in
this case. The tab does not mutate its own props so it the parent component should subscribe to the
event and set the props accordingly.
When the task completes a `completed` event is emitted. No action is needed on this event, it is emitted
so that the parent component can update any other data or UI elements if needed.
When the close button is clicked a `closeTask` event is emitted. It is the responsibility of the parent
component to set the `taskId` and `taskUrl` props back to `null`, returning the tab to **Unstarted**
mode.
-->
<template>
<div ref="actionTab" uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="control-component uk-padding-small">
<div v-if="!taskStarted" class="uk-padding-small">
<slot name="controls"></slot>
</div>
<div v-if="taskStarted">
<h2 v-if="taskInfoTitle" style="text-align: center">{{ taskInfoTitle }}</h2>
<mini-stream-display v-if="taskInfoStream" />
<action-log-display id="log-display" :log="log" :task-status="taskStatus" />
<div class="uk-width-1-1 uk-flex uk-flex-center">
<div class="uk-width-2-3">
<action-button
v-if="taskRunning"
:thing="thing"
:action="action"
:force-id="taskId"
:force-url="taskUrl"
submit-label=""
:can-terminate="true"
@completed="$emit('completed')"
@update:task-status="taskStatus = $event"
@update:progress="progress = $event"
@update:log="log = $event"
/>
<button
v-if="!taskRunning"
type="button"
class="uk-button uk-width-1-1 uk-position-relative"
@click="closeTask"
>
Close
</button>
</div>
</div>
<slot name="task-info"></slot>
</div>
</div>
<div class="main-view uk-width-expand uk-height-1-1">
<slot></slot>
</div>
</div>
</template>
<script>
import actionLogDisplay from "../labThingsComponents/actionLogDisplay.vue";
import MiniStreamDisplay from "../genericComponents/miniStreamDisplay.vue";
import ActionButton from "../labThingsComponents/actionButton.vue";
import { useIntersectionObserver } from "@vueuse/core";
export default {
name: "ActionTab",
components: {
actionLogDisplay,
MiniStreamDisplay,
ActionButton,
},
props: {
action: {
type: String,
required: true,
},
thing: {
type: String,
required: true,
},
taskId: {
type: [String, null],
required: true,
},
taskUrl: {
type: [String, null],
required: true,
},
taskInfoTitle: {
type: String,
required: false,
default: null,
},
taskInfoStream: {
type: Boolean,
required: false,
default: false,
},
},
emits: ["closeTask", "completed", "actionStartedExternally"],
data() {
return {
taskStatus: "pending",
progress: null,
log: [],
pollTimer: null,
};
},
computed: {
taskStarted() {
return this.taskId && this.taskUrl;
},
taskRunning() {
return (this.taskStatus == "running") | (this.taskStatus == "pending");
},
},
mounted() {
useIntersectionObserver(
this.$refs.actionTab,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{
threshold: 0.0, // Adjust as needed
},
);
},
methods: {
/**
* Reset the data needed for actions to be polled.
*/
resetData() {
this.taskStatus = "pending";
this.log = [];
},
closeTask() {
// Reset task status to pending so that it is re-read next time an action starts.
this.resetData();
this.$emit("closeTask");
},
visibilityChanged(isVisible) {
this.isVisible = isVisible;
if (this.isVisible) {
this.startTimer();
} else {
this.stopTimer();
}
},
startTimer() {
if (this.pollTimer) return;
this.pollTimer = setInterval(() => {
this.checkIfStartedExternally();
}, 1000);
},
stopTimer() {
if (!this.pollTimer) return;
clearInterval(this.pollTimer);
this.pollTimer = null;
},
async checkIfStartedExternally() {
if (!this.taskStarted | !this.taskRunning) {
const ongoingTask = await this.getOngingAction(this.thing, this.action);
if (ongoingTask) {
this.resetData();
const taskUrl = ongoingTask.links.find((t) => t.rel == "self").href;
this.$emit("actionStartedExternally", ongoingTask.id, taskUrl);
}
}
},
},
};
</script>
<style scoped>
.control-component {
width: 33%;
}
#log-display {
height: 20em;
}
</style>

View file

@ -0,0 +1,122 @@
<template>
<!-- Workflow Selection Dropdown -->
<div ref="slideScanControls" class="uk-margin">
<label class="uk-form-label">Workflow</label>
<select
class="uk-select uk-form-small"
:value="workflowName"
@change="setWorkflow($event.target.value)"
>
<option v-for="(label, name) in workflowOptions" :key="name" :value="name">
{{ label }}
</option>
</select>
</div>
<server-specified-interface :elements="workflowSettings" @request-update="readSettings" />
<ul uk-accordion="multiple: true">
<li class="uk-open">
<a class="uk-accordion-title" href="#">Stitching Settings</a>
<div class="uk-accordion-content">
<div class="uk-margin">
<propertyControl
thing-name="smart_scan"
property-name="stitch_automatically"
label="Automatically Stitch Images Together"
/>
</div>
<div class="uk-margin">
<propertyControl
thing-name="smart_scan"
property-name="stitch_tiff"
label="When Stitching, Produce a Pyramidal TIFF"
/>
</div>
</div>
</li>
</ul>
</template>
<script>
import propertyControl from "@/components/labThingsComponents/propertyControl.vue";
import ServerSpecifiedInterface from "@/components/labThingsComponents/serverSpecifiedInterface.vue";
import { useIntersectionObserver } from "@vueuse/core";
export default {
name: "SlideScanControls",
components: {
propertyControl,
ServerSpecifiedInterface,
},
data() {
return {
workflowName: undefined,
workflowSettings: [],
workflowOptions: [],
};
},
async created() {
this.readSettings();
this.workflowOptions = await this.readThingProperty(
"smart_scan",
"workflow_display_names",
true,
);
},
mounted() {
// sets visibilityChanged to true or false, which can then update settings if needed
useIntersectionObserver(
this.$refs.slideScanControls,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{
threshold: 0.0,
},
);
},
methods: {
visibilityChanged(isVisible) {
if (isVisible) {
this.readSettings();
}
},
async readSettings() {
this.workflowName = await this.readThingProperty("smart_scan", "workflow_name");
if (!this.workflowName) {
console.warn("Could not read workflow_name, using default");
this.workflowName = "histo_scan_workflow";
}
if (this.workflowName) {
this.ready = await this.readThingProperty(this.workflowName, "ready", true);
this.workflowSettings =
(await this.getThingEndpoint(this.workflowName, "settings_ui")) || [];
}
},
async setWorkflow(name) {
try {
this.workflowName = name;
await this.writeThingProperty("smart_scan", "workflow_name", name);
// refresh UI
await this.readSettings();
} catch (err) {
this.modalError(err);
// revert if server rejected
this.workflowName = await this.readThingProperty("smart_scan", "workflow_name", true);
}
},
},
};
</script>
<style scoped></style>

View file

@ -1,87 +1,45 @@
<template> <template>
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove"> <actionTab
<div class="control-component uk-padding-small"> thing="smart_scan"
<div v-show="!scanning" ref="slideScanView" class="uk-padding-small"> action="sample_scan"
<!-- Workflow Selection Dropdown --> :task-id="taskId"
<div class="uk-margin"> :task-url="taskUrl"
<label class="uk-form-label">Workflow</label> :task-info-title="infoPaneTitle"
:task-info-stream="displayImageOnRight"
<select @completed="onScanCompleted"
class="uk-select uk-form-small" @close-task="closeTask"
:value="workflowName" @action-started-externally="startScanning"
@change="setWorkflow($event.target.value)" >
> <!-- MainView -->
<option v-for="(label, name) in workflowOptions" :key="name" :value="name"> <img
{{ label }} v-if="displayImageOnRight"
</option> id="last-stitched-image"
</select> class="image-fit"
</div> :src="lastStitchedImage"
<server-specified-interface :elements="workflowSettings" @request-update="readSettings" /> />
<ul uk-accordion="multiple: true"> <streamDisplay v-else />
<li class="uk-open"> <template #controls>
<a class="uk-accordion-title" href="#">Stitching Settings</a> <slideScanControls />
<div class="uk-accordion-content"> <label class="uk-form-label" for="form-stacked-text">Sample ID</label>
<div class="uk-margin"> <div class="uk-form-controls">
<propertyControl <input v-model="scan_name" class="uk-input uk-form-small" type="text" name="Scan Name" />
thing-name="smart_scan"
property-name="stitch_automatically"
label="Automatically Stitch Images Together"
/>
</div>
<div class="uk-margin">
<propertyControl
thing-name="smart_scan"
property-name="stitch_tiff"
label="When Stitching, Produce a Pyramidal TIFF"
/>
</div>
</div>
</li>
</ul>
<label class="uk-form-label" for="form-stacked-text">Sample ID</label>
<div class="uk-form-controls">
<input v-model="scan_name" class="uk-input uk-form-small" type="text" name="Scan Name" />
</div>
<div class="uk-margin">
<action-button
ref="smartScanButton"
thing="smart_scan"
action="sample_scan"
:submit-data="{ scan_name: scan_name }"
submit-label="Start Smart Scan"
:can-terminate="true"
@task-started="startScanning"
@update:task-status="taskStatus = $event"
@update:progress="progress = $event"
@update:log="log = $event"
/>
</div>
</div> </div>
<div v-show="scanning"> <div class="uk-margin">
<h2 v-if="displayImageOnRight" style="text-align: center">Live stitching preview</h2> <action-button
<mini-stream-display v-if="displayImageOnRight" /> ref="smartScanButton"
<action-log-display id="log-display" :log="log" :task-status="taskStatus" /> thing="smart_scan"
<action-progress-bar :progress="progress" :task-status="taskStatus" /> action="sample_scan"
<button :submit-data="{ scan_name: scan_name }"
v-if="cancellable" submit-label="Start Smart Scan"
type="button" @task-started="startScanning"
class="uk-button uk-button-danger uk-width-1-1" />
@click="$refs.smartScanButton.terminateTask()" </div>
> </template>
Cancel <template #task-info>
</button> <div class="uk-width-1-1 uk-flex uk-flex-center">
<div v-if="!cancellable" class="uk-margin uk-grid-small uk-child-width-expand" uk-grid> <div class="uk-width-2-3">
<button
type="button"
class="uk-button"
@click="
scanning = false;
lastStitchedImage = null;
"
>
Close
</button>
<action-button <action-button
v-if="scanComplete"
thing="smart_scan" thing="smart_scan"
action="download_zip" action="download_zip"
submit-label="Download ZIP" submit-label="Download ZIP"
@ -93,114 +51,52 @@
/> />
</div> </div>
</div> </div>
<h3 v-if="scanning">Scan ID: {{ lastScanName }}</h3> <h3 v-if="scanning" class="uk-margin-left">Scan ID: {{ lastScanName }}</h3>
</div> </template>
<div class="view-image uk-width-expand uk-height-1-1"> </actionTab>
<img
v-if="displayImageOnRight"
id="last-stitched-image"
class="image-fit"
:src="lastStitchedImage"
/>
<streamDisplay v-else />
</div>
</div>
</template> </template>
<script> <script>
import actionTab from "./actionTab.vue";
import slideScanControls from "./slideScanComponents/slideScanControls.vue";
import streamDisplay from "./streamContent.vue"; import streamDisplay from "./streamContent.vue";
import propertyControl from "../labThingsComponents/propertyControl.vue";
import ServerSpecifiedInterface from "../labThingsComponents/serverSpecifiedInterface.vue";
import actionLogDisplay from "../labThingsComponents/actionLogDisplay.vue";
import actionProgressBar from "../labThingsComponents/actionProgressBar.vue";
import MiniStreamDisplay from "../genericComponents/miniStreamDisplay.vue";
import ActionButton from "../labThingsComponents/actionButton.vue"; import ActionButton from "../labThingsComponents/actionButton.vue";
import { useIntersectionObserver } from "@vueuse/core";
export default { export default {
name: "SlideScanContent", name: "SlideScanContent",
components: { components: {
actionTab,
slideScanControls,
streamDisplay, streamDisplay,
propertyControl,
actionLogDisplay,
actionProgressBar,
MiniStreamDisplay,
ActionButton, ActionButton,
ServerSpecifiedInterface,
}, },
data() { data() {
return { return {
lastScanName: null, lastScanName: null,
scanning: false, taskId: null,
taskStatus: "pending", taskUrl: null,
correlateStatus: "",
stitchFromStageStatus: "",
progress: null,
log: [],
lastStitchedImage: null, lastStitchedImage: null,
scanComplete: false,
scan_name: "", scan_name: "",
workflowName: undefined,
workflowSettings: [],
workflowOptions: [],
}; };
}, },
computed: { computed: {
cancellable() { scanning() {
return (this.taskStatus == "running") | (this.taskStatus == "pending"); return this.taskId && this.taskUrl;
}, },
displayImageOnRight() { displayImageOnRight() {
return this.scanning & (this.lastStitchedImage !== null); return this.scanning && this.lastStitchedImage !== null;
},
infoPaneTitle() {
if (!this.displayImageOnRight) return null;
return "Live stitching preview";
}, },
},
async created() {
this.readSettings();
this.workflowOptions = await this.readThingProperty(
"smart_scan",
"workflow_display_names",
true,
);
},
mounted() {
useIntersectionObserver(
this.$refs.slideScanView,
([{ isIntersecting }]) => {
this.visibilityChanged(isIntersecting);
},
{
threshold: 0.0,
},
);
}, },
methods: { methods: {
visibilityChanged(isVisible) {
if (isVisible) {
this.readSettings();
}
},
async readSettings() {
this.workflowName = await this.readThingProperty("smart_scan", "workflow_name");
if (!this.workflowName) {
console.warn("Could not read workflow_name, using default");
this.workflowName = "histo_scan_workflow";
}
if (this.workflowName) {
this.ready = await this.readThingProperty(this.workflowName, "ready", true);
this.workflowSettings =
(await this.getThingEndpoint(this.workflowName, "settings_ui")) || [];
}
},
onScanError: function (error) {
this.scanRunning = false;
this.modalError(error);
},
/** /**
* Transition the UI into "scanning" mode and begin polling for scan updates. * Transition the UI into "scanning" mode and begin polling for scan updates.
* *
@ -220,13 +116,25 @@ export default {
* - clears any previous preview image * - clears any previous preview image
* - starts the polling loop that fetches scan progress and preview images * - starts the polling loop that fetches scan progress and preview images
*/ */
startScanning() { startScanning(id, url) {
this.lastStitchedImage = null; this.lastStitchedImage = null;
this.scanning = true; this.lastScanName = null;
this.taskId = id;
this.taskUrl = url;
this.scanComplete = false;
setTimeout(this.pollScan, 1000); setTimeout(this.pollScan, 1000);
}, },
onScanCompleted() {
this.scanComplete = true;
},
closeTask() {
this.taskId = null;
this.taskUrl = null;
this.lastStitchedImage = null;
this.scanComplete = false;
},
async pollScan() { async pollScan() {
if (this.cancellable) { if (!this.scanComplete) {
// 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) {
@ -237,21 +145,6 @@ export default {
setTimeout(this.pollScan, 1000); // keep rescheduling until it's stopped setTimeout(this.pollScan, 1000); // keep rescheduling until it's stopped
} }
}, },
async setWorkflow(name) {
try {
this.workflowName = name;
await this.writeThingProperty("smart_scan", "workflow_name", name);
// refresh UI
await this.readSettings();
} catch (err) {
this.modalError(err);
// revert if server rejected
this.workflowName = await this.readThingProperty("smart_scan", "workflow_name", true);
}
},
async downloadZipFile(response) { async downloadZipFile(response) {
const scan_name = response.input.scan_name; const scan_name = response.input.scan_name;
const filename = `${scan_name}_images.zip`; const filename = `${scan_name}_images.zip`;
@ -266,11 +159,4 @@ export default {
}; };
</script> </script>
<style scoped> <style scoped></style>
#log-display {
height: 20em;
}
.control-component {
width: 33%;
}
</style>

View file

@ -126,6 +126,20 @@ export default {
return null; return null;
} }
}, },
/**
* Find and ongoing Action and return its task response.
*
* Returns null if there is no ongoing action
*/
async getOngingAction(thing, action) {
let response = await this.findOngoingActions(thing, action);
// Exit if response is null, due to an error.
if (response == null) return null;
// Check for a task that is ongoing.
// We can't handle multiple tasks ongoing, so this picks the first.
// ?? Is the "Nullish Coalescing-Operator" to turn undefined into null.
return response.data.find((t) => ["pending", "running"].includes(t.status)) ?? null;
},
thingActionUrl(thing, action, allowUndefined = false) { thingActionUrl(thing, action, allowUndefined = false) {
let url = this.$store.getters["wot/thingActionUrl"]( let url = this.$store.getters["wot/thingActionUrl"](
thing, thing,