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

@ -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>
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
<div class="control-component uk-padding-small">
<div v-show="!scanning" ref="slideScanView" class="uk-padding-small">
<!-- Workflow Selection Dropdown -->
<div 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>
<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>
<actionTab
thing="smart_scan"
action="sample_scan"
:task-id="taskId"
:task-url="taskUrl"
:task-info-title="infoPaneTitle"
:task-info-stream="displayImageOnRight"
@completed="onScanCompleted"
@close-task="closeTask"
@action-started-externally="startScanning"
>
<!-- MainView -->
<img
v-if="displayImageOnRight"
id="last-stitched-image"
class="image-fit"
:src="lastStitchedImage"
/>
<streamDisplay v-else />
<template #controls>
<slideScanControls />
<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 v-show="scanning">
<h2 v-if="displayImageOnRight" style="text-align: center">Live stitching preview</h2>
<mini-stream-display v-if="displayImageOnRight" />
<action-log-display id="log-display" :log="log" :task-status="taskStatus" />
<action-progress-bar :progress="progress" :task-status="taskStatus" />
<button
v-if="cancellable"
type="button"
class="uk-button uk-button-danger uk-width-1-1"
@click="$refs.smartScanButton.terminateTask()"
>
Cancel
</button>
<div v-if="!cancellable" class="uk-margin uk-grid-small uk-child-width-expand" uk-grid>
<button
type="button"
class="uk-button"
@click="
scanning = false;
lastStitchedImage = null;
"
>
Close
</button>
<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"
@task-started="startScanning"
/>
</div>
</template>
<template #task-info>
<div class="uk-width-1-1 uk-flex uk-flex-center">
<div class="uk-width-2-3">
<action-button
v-if="scanComplete"
thing="smart_scan"
action="download_zip"
submit-label="Download ZIP"
@ -93,114 +51,52 @@
/>
</div>
</div>
<h3 v-if="scanning">Scan ID: {{ lastScanName }}</h3>
</div>
<div class="view-image uk-width-expand uk-height-1-1">
<img
v-if="displayImageOnRight"
id="last-stitched-image"
class="image-fit"
:src="lastStitchedImage"
/>
<streamDisplay v-else />
</div>
</div>
<h3 v-if="scanning" class="uk-margin-left">Scan ID: {{ lastScanName }}</h3>
</template>
</actionTab>
</template>
<script>
import actionTab from "./actionTab.vue";
import slideScanControls from "./slideScanComponents/slideScanControls.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 { useIntersectionObserver } from "@vueuse/core";
export default {
name: "SlideScanContent",
components: {
actionTab,
slideScanControls,
streamDisplay,
propertyControl,
actionLogDisplay,
actionProgressBar,
MiniStreamDisplay,
ActionButton,
ServerSpecifiedInterface,
},
data() {
return {
lastScanName: null,
scanning: false,
taskStatus: "pending",
correlateStatus: "",
stitchFromStageStatus: "",
progress: null,
log: [],
taskId: null,
taskUrl: null,
lastStitchedImage: null,
scanComplete: false,
scan_name: "",
workflowName: undefined,
workflowSettings: [],
workflowOptions: [],
};
},
computed: {
cancellable() {
return (this.taskStatus == "running") | (this.taskStatus == "pending");
scanning() {
return this.taskId && this.taskUrl;
},
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: {
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.
*
@ -220,13 +116,25 @@ export default {
* - clears any previous preview image
* - starts the polling loop that fetches scan progress and preview images
*/
startScanning() {
startScanning(id, url) {
this.lastStitchedImage = null;
this.scanning = true;
this.lastScanName = null;
this.taskId = id;
this.taskUrl = url;
this.scanComplete = false;
setTimeout(this.pollScan, 1000);
},
onScanCompleted() {
this.scanComplete = true;
},
closeTask() {
this.taskId = null;
this.taskUrl = null;
this.lastStitchedImage = null;
this.scanComplete = false;
},
async pollScan() {
if (this.cancellable) {
if (!this.scanComplete) {
// while the scan is running
let mtime = await this.readThingProperty("smart_scan", "latest_preview_stitch_time", true);
if (mtime !== null) {
@ -237,21 +145,6 @@ export default {
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) {
const scan_name = response.input.scan_name;
const filename = `${scan_name}_images.zip`;
@ -266,11 +159,4 @@ export default {
};
</script>
<style scoped>
#log-display {
height: 20em;
}
.control-component {
width: 33%;
}
</style>
<style scoped></style>