Merge branch 'dynamic-ui' into 'v3'
Increase number of UI elements that can be specified in the server Closes #508 and #644 See merge request openflexure/openflexure-microscope-server!530
This commit is contained in:
commit
2909194d61
17 changed files with 810 additions and 281 deletions
|
|
@ -13,7 +13,11 @@ from pydantic import BaseModel
|
||||||
|
|
||||||
import labthings_fastapi as lt
|
import labthings_fastapi as lt
|
||||||
|
|
||||||
from openflexure_microscope_server.ui import PropertyControl, property_control_for
|
from openflexure_microscope_server.ui import (
|
||||||
|
UI_ELEMENT_RESPONSE,
|
||||||
|
UIElementList,
|
||||||
|
property_control_for,
|
||||||
|
)
|
||||||
|
|
||||||
SettingsType = TypeVar("SettingsType", bound=BaseModel)
|
SettingsType = TypeVar("SettingsType", bound=BaseModel)
|
||||||
BackgroundType = TypeVar("BackgroundType", bound=BaseModel)
|
BackgroundType = TypeVar("BackgroundType", bound=BaseModel)
|
||||||
|
|
@ -72,9 +76,9 @@ class BackgroundDetectAlgorithm(lt.Thing):
|
||||||
"Each background detect algorithm must implement an set_background method."
|
"Each background detect algorithm must implement an set_background method."
|
||||||
)
|
)
|
||||||
|
|
||||||
@lt.property
|
@lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE)
|
||||||
def settings_ui(self) -> list[PropertyControl]:
|
def settings_ui(self) -> UIElementList:
|
||||||
"""A list of PropertyControl objects to create the settings in the UI."""
|
"""Return the UI for the background detect settings."""
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"Each background detect algorithm must implement an settings_ui method."
|
"Each background detect algorithm must implement an settings_ui method."
|
||||||
)
|
)
|
||||||
|
|
@ -113,15 +117,19 @@ class ColourChannelDetectLUV(BackgroundDetectAlgorithm):
|
||||||
image to be labeled as containing sample.
|
image to be labeled as containing sample.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@lt.property
|
@lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE)
|
||||||
def settings_ui(self) -> list[PropertyControl]:
|
def settings_ui(self) -> UIElementList:
|
||||||
"""A list of PropertyControl objects to create the settings in the UI."""
|
"""Return the UI for the background detect settings."""
|
||||||
return [
|
return UIElementList(
|
||||||
property_control_for(self, "channel_tolerance", label="Channel Tolerance"),
|
[
|
||||||
property_control_for(
|
property_control_for(
|
||||||
self, "min_sample_coverage", label="Sample Coverage Required (%)"
|
self, "channel_tolerance", label="Channel Tolerance"
|
||||||
),
|
),
|
||||||
]
|
property_control_for(
|
||||||
|
self, "min_sample_coverage", label="Sample Coverage Required (%)"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
@lt.property
|
@lt.property
|
||||||
def ready(self) -> bool:
|
def ready(self) -> bool:
|
||||||
|
|
@ -237,15 +245,19 @@ class ChannelDeviationLUV(BackgroundDetectAlgorithm):
|
||||||
image to be labeled as containing sample.
|
image to be labeled as containing sample.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@lt.property
|
@lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE)
|
||||||
def settings_ui(self) -> list[PropertyControl]:
|
def settings_ui(self) -> UIElementList:
|
||||||
"""A list of PropertyControl objects to create the settings in the UI."""
|
"""Return the UI for the background detect settings."""
|
||||||
return [
|
return UIElementList(
|
||||||
property_control_for(self, "channel_tolerance", label="Channel Tolerance"),
|
[
|
||||||
property_control_for(
|
property_control_for(
|
||||||
self, "min_sample_coverage", label="Sample Coverage Required (%)"
|
self, "channel_tolerance", label="Channel Tolerance"
|
||||||
),
|
),
|
||||||
]
|
property_control_for(
|
||||||
|
self, "min_sample_coverage", label="Sample Coverage Required (%)"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
@lt.property
|
@lt.property
|
||||||
def ready(self) -> bool:
|
def ready(self) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ This module contains the base ``ScanWorkflow`` class that all workflows should s
|
||||||
as well as specific workflows.
|
as well as specific workflows.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import (
|
from typing import (
|
||||||
Generic,
|
Generic,
|
||||||
|
|
@ -41,7 +39,15 @@ from openflexure_microscope_server.things.background_detect import (
|
||||||
from openflexure_microscope_server.things.camera import BaseCamera, CaptureParams
|
from openflexure_microscope_server.things.camera import BaseCamera, CaptureParams
|
||||||
from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper
|
from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper
|
||||||
from openflexure_microscope_server.things.stage import BaseStage
|
from openflexure_microscope_server.things.stage import BaseStage
|
||||||
from openflexure_microscope_server.ui import PropertyControl, property_control_for
|
from openflexure_microscope_server.ui import (
|
||||||
|
UI_ELEMENT_RESPONSE,
|
||||||
|
Accordion,
|
||||||
|
HeaderBlock,
|
||||||
|
TextBlock,
|
||||||
|
UIElementList,
|
||||||
|
action_button_for,
|
||||||
|
property_control_for,
|
||||||
|
)
|
||||||
|
|
||||||
SettingModelType = TypeVar("SettingModelType", bound=BaseModel)
|
SettingModelType = TypeVar("SettingModelType", bound=BaseModel)
|
||||||
|
|
||||||
|
|
@ -160,9 +166,9 @@ class ScanWorkflow(Generic[SettingModelType], lt.Thing):
|
||||||
|
|
||||||
return True, focus_height
|
return True, focus_height
|
||||||
|
|
||||||
@lt.property
|
@lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE)
|
||||||
def settings_ui(self) -> list[PropertyControl]:
|
def settings_ui(self) -> UIElementList:
|
||||||
"""A list of PropertyControl objects to create the settings in the scan tab."""
|
"""Return the UI for the workflow's settings in the scan tab."""
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
"Each scan workflow must implement a settings_ui method."
|
"Each scan workflow must implement a settings_ui method."
|
||||||
)
|
)
|
||||||
|
|
@ -556,35 +562,96 @@ class HistoScanWorkflow(RectGridWorkflow[HistoScanSettingsModel]):
|
||||||
|
|
||||||
return imaged, focus_height
|
return imaged, focus_height
|
||||||
|
|
||||||
@lt.property
|
@lt.action
|
||||||
def settings_ui(self) -> list[PropertyControl]:
|
def check_background(self) -> str:
|
||||||
"""A list of PropertyControl objects to create the settings in the scan tab."""
|
"""Check if sample is background.
|
||||||
return [
|
|
||||||
property_control_for(
|
This action is a pre-run check for feeding back to the user.
|
||||||
self, "overlap", label="Image Overlap (0.1-0.7)", step=0.05
|
"""
|
||||||
),
|
image_array = self._cam.grab_as_array(stream_name="lores")
|
||||||
property_control_for(
|
is_sample, bg_message = self._background_detector.image_is_sample(image_array)
|
||||||
self, "stack_images_to_save", label="Images in Stack to Save"
|
label = "sample" if is_sample else "background"
|
||||||
),
|
|
||||||
property_control_for(
|
return f"Current image is {label} ({bg_message})"
|
||||||
self,
|
|
||||||
"stack_min_images_to_test",
|
@lt.action
|
||||||
label="Minimum number of images to test for focus",
|
def set_background(self) -> None:
|
||||||
),
|
"""Set the background for this background detector.
|
||||||
property_control_for(self, "stack_dz", label="Stack dz (steps)", step=5),
|
|
||||||
property_control_for(
|
This sets the background for this workflow's background detector as opposed to
|
||||||
self, "autofocus_dz", label="Autofocus Range (steps)", step=200
|
the active background detector for the camera.
|
||||||
),
|
"""
|
||||||
property_control_for(
|
image_array = self._cam.grab_as_array(stream_name="lores")
|
||||||
self, "max_range", label="Maximum Distance (steps)", step=1000
|
self._background_detector.set_background(image_array)
|
||||||
),
|
|
||||||
property_control_for(
|
@lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE)
|
||||||
self, "skip_background", label="Detect and Skip Empty Fields"
|
def settings_ui(self) -> UIElementList:
|
||||||
),
|
"""Return the UI for the workflow's settings in the scan tab."""
|
||||||
property_control_for(
|
scan_settings = UIElementList(
|
||||||
self, "equal_distances", label="Set Equal x and y Distances"
|
[
|
||||||
),
|
property_control_for(
|
||||||
]
|
self, "overlap", label="Image Overlap (0.1-0.7)", step=0.05
|
||||||
|
),
|
||||||
|
property_control_for(
|
||||||
|
self, "stack_images_to_save", label="Images in Stack to Save"
|
||||||
|
),
|
||||||
|
property_control_for(
|
||||||
|
self,
|
||||||
|
"stack_min_images_to_test",
|
||||||
|
label="Minimum number of images to test for focus",
|
||||||
|
),
|
||||||
|
property_control_for(
|
||||||
|
self, "stack_dz", label="Stack dz (steps)", step=5
|
||||||
|
),
|
||||||
|
property_control_for(
|
||||||
|
self, "autofocus_dz", label="Autofocus Range (steps)", step=200
|
||||||
|
),
|
||||||
|
property_control_for(
|
||||||
|
self, "max_range", label="Maximum Distance (steps)", step=1000
|
||||||
|
),
|
||||||
|
property_control_for(
|
||||||
|
self, "skip_background", label="Detect and Skip Empty Fields"
|
||||||
|
),
|
||||||
|
property_control_for(
|
||||||
|
self, "equal_distances", label="Set Equal x and y Distances"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
background_ui = self._background_detector.settings_ui()
|
||||||
|
set_bg_button = action_button_for(
|
||||||
|
self,
|
||||||
|
"set_background",
|
||||||
|
poll_interval=0.1,
|
||||||
|
submit_label="Set Background",
|
||||||
|
can_terminate=False,
|
||||||
|
notify_on_success=True,
|
||||||
|
success_message="Background image has been updated",
|
||||||
|
update_interface_on_response=True,
|
||||||
|
)
|
||||||
|
check_bg_button = action_button_for(
|
||||||
|
self,
|
||||||
|
"check_background",
|
||||||
|
poll_interval=0.1,
|
||||||
|
submit_label="Check Current Image",
|
||||||
|
disabled=not self._background_detector.ready,
|
||||||
|
can_terminate=False,
|
||||||
|
notify_on_success=True,
|
||||||
|
response_is_success_message=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
background_ui.root += [set_bg_button, check_bg_button]
|
||||||
|
|
||||||
|
return UIElementList(
|
||||||
|
[
|
||||||
|
HeaderBlock(text=self.display_name, level=4),
|
||||||
|
TextBlock(text=self.ui_blurb),
|
||||||
|
Accordion(title="Background Detect", children=background_ui),
|
||||||
|
Accordion(
|
||||||
|
title="Scan Settings",
|
||||||
|
children=scan_settings,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RegularGridSettingsModel(RectGridSettingsModel):
|
class RegularGridSettingsModel(RectGridSettingsModel):
|
||||||
|
|
@ -668,17 +735,31 @@ class RegularGridWorkflow(RectGridWorkflow[RegularGridSettingsModel]):
|
||||||
save_resolution=settings.capture_params.save_resolution,
|
save_resolution=settings.capture_params.save_resolution,
|
||||||
)
|
)
|
||||||
|
|
||||||
@lt.property
|
@lt.endpoint("get", "settings_ui", responses=UI_ELEMENT_RESPONSE)
|
||||||
def settings_ui(self) -> list[PropertyControl]:
|
def settings_ui(self) -> UIElementList:
|
||||||
"""A list of PropertyControl objects to create the settings in the scan tab."""
|
"""Return the UI for the workflow's settings in the scan tab."""
|
||||||
return [
|
scan_settings = UIElementList(
|
||||||
property_control_for(
|
[
|
||||||
self, "overlap", label="Image Overlap (0.1-0.7)", step=0.05
|
property_control_for(
|
||||||
),
|
self, "overlap", label="Image Overlap (0.1-0.7)", step=0.05
|
||||||
property_control_for(self, "x_count", label="Number of columns"),
|
),
|
||||||
property_control_for(self, "y_count", label="Number of rows"),
|
property_control_for(self, "x_count", label="Number of columns"),
|
||||||
property_control_for(self, "autofocus_dz", label="Autofocus Range (steps)"),
|
property_control_for(self, "y_count", label="Number of rows"),
|
||||||
]
|
property_control_for(
|
||||||
|
self, "autofocus_dz", label="Autofocus Range (steps)"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return UIElementList(
|
||||||
|
[
|
||||||
|
HeaderBlock(text=self.display_name, level=4),
|
||||||
|
TextBlock(text=self.ui_blurb),
|
||||||
|
Accordion(
|
||||||
|
title="Scan Settings",
|
||||||
|
children=scan_settings,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SnakeWorkflow(RegularGridWorkflow):
|
class SnakeWorkflow(RegularGridWorkflow):
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,155 @@
|
||||||
"""Functionality for communicating the required user interface for a thing."""
|
"""Functionality for communicating the required user interface for a thing."""
|
||||||
|
|
||||||
from typing import Any, Optional
|
from html import escape
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from typing import Annotated, Any, Literal, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, BeforeValidator, Field, RootModel
|
||||||
|
|
||||||
import labthings_fastapi as lt
|
import labthings_fastapi as lt
|
||||||
|
|
||||||
|
# Only allowed tags are italic, bold, and link. Also allows self closing br tags.
|
||||||
|
ALLOWED_TAGS = {"i", "b", "a"}
|
||||||
|
|
||||||
|
ALLOWED_SCHEMES = {"http", "https"}
|
||||||
|
|
||||||
|
|
||||||
|
def strip_control_chars(input_string: str) -> str:
|
||||||
|
"""Remove unprintable characters."""
|
||||||
|
return "".join(char for char in input_string if char.isprintable())
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_http_url(url: str) -> Optional[str]:
|
||||||
|
"""Santitise any url only allowing http and https without queries."""
|
||||||
|
url = strip_control_chars(url)
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ALLOWED_SCHEMES:
|
||||||
|
return None
|
||||||
|
if not parsed.netloc:
|
||||||
|
return None
|
||||||
|
fragment_str = "#" + parsed.fragment if parsed.fragment else ""
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}{parsed.path}{fragment_str}"
|
||||||
|
|
||||||
|
|
||||||
|
class SafeHTMLParser(HTMLParser):
|
||||||
|
"""An HTMLParser that only allows a very limited subset of HTML."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialise the parser."""
|
||||||
|
super().__init__()
|
||||||
|
self.output = ""
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]) -> None:
|
||||||
|
"""Append a start tag found in the HTML if it is in the allowed list.
|
||||||
|
|
||||||
|
This is called by the base class when a start tag is found. We only append
|
||||||
|
the tag if it is our allowed list. We re-write the tag so no extra attributes
|
||||||
|
can be added.
|
||||||
|
|
||||||
|
No tags can have attributes except for ``a`` tags, why only allow ``href``. We then
|
||||||
|
append ``target="_blank" rel="noopener noreferrer"`` to ensure the link opens
|
||||||
|
in a new window.
|
||||||
|
|
||||||
|
:param tag: the tag name
|
||||||
|
:param attrs: the attributes for the tag.
|
||||||
|
"""
|
||||||
|
if tag not in ALLOWED_TAGS:
|
||||||
|
return
|
||||||
|
|
||||||
|
if tag == "a":
|
||||||
|
href = None
|
||||||
|
for attr, value in attrs:
|
||||||
|
if attr == "href" and value is not None:
|
||||||
|
# If it is not a httplink this will stay as None
|
||||||
|
href = sanitize_http_url(value.strip())
|
||||||
|
|
||||||
|
if href:
|
||||||
|
safe_href = escape(href, quote=True)
|
||||||
|
# Always in a new tab
|
||||||
|
self.output += (
|
||||||
|
f'<a href="{safe_href}" target="_blank" rel="noopener noreferrer">'
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.output += "<a>"
|
||||||
|
else:
|
||||||
|
self.output += f"<{tag}>"
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
"""Append the end tag only if it is in the allowed list."""
|
||||||
|
if tag in ALLOWED_TAGS:
|
||||||
|
self.output += f"</{tag}>"
|
||||||
|
|
||||||
|
def handle_startendtag(
|
||||||
|
self, tag: str, _attrs: list[tuple[str, Optional[str]]]
|
||||||
|
) -> None:
|
||||||
|
"""Append a self-closing tag if it is a new line."""
|
||||||
|
if tag == "br":
|
||||||
|
self.output += "<br/>"
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
"""Append any data inside tags after escaping it."""
|
||||||
|
self.output += escape(data)
|
||||||
|
|
||||||
|
def handle_entityref(self, name: str) -> None:
|
||||||
|
"""Append any named character."""
|
||||||
|
self.output += f"&{name};"
|
||||||
|
|
||||||
|
def handle_charref(self, name: str) -> None:
|
||||||
|
"""Append any character references."""
|
||||||
|
self.output += f"&#{name};"
|
||||||
|
|
||||||
|
|
||||||
|
def sanitise_html(html: str) -> str:
|
||||||
|
"""Santitise HTML to only have a small list of allowed tags.
|
||||||
|
|
||||||
|
Tags allowed without attrs: ``<i>, <b>,``
|
||||||
|
|
||||||
|
Tags allowed with attrs: ``<a>`` is allowed with ``href`` only. This automatically
|
||||||
|
appends ``target="_blank" rel="noopener noreferrer"`` so the link opens externally.
|
||||||
|
|
||||||
|
Self closing tags allowed ``<br>``.
|
||||||
|
|
||||||
|
:param html: The input HTML.
|
||||||
|
|
||||||
|
:return: The sanitised HTML.
|
||||||
|
"""
|
||||||
|
parser = SafeHTMLParser()
|
||||||
|
parser.feed(html)
|
||||||
|
parser.close()
|
||||||
|
return parser.output
|
||||||
|
|
||||||
|
|
||||||
|
HtmlFragment = Annotated[str, BeforeValidator(sanitise_html)]
|
||||||
|
|
||||||
|
|
||||||
|
class HeaderBlock(BaseModel):
|
||||||
|
"""The data required for header."""
|
||||||
|
|
||||||
|
element_type: Literal["header_block"] = "header_block"
|
||||||
|
text: HtmlFragment
|
||||||
|
"""The header text (can include basic HTML tags)."""
|
||||||
|
|
||||||
|
level: int = Field(default=2, ge=1, le=6)
|
||||||
|
"""The header level. A number from 1-6."""
|
||||||
|
|
||||||
|
|
||||||
|
class TextBlock(BaseModel):
|
||||||
|
"""The data required for creating a block text."""
|
||||||
|
|
||||||
|
element_type: Literal["text_block"] = "text_block"
|
||||||
|
text: HtmlFragment
|
||||||
|
"""The text (can include basic HTML tags)."""
|
||||||
|
|
||||||
|
|
||||||
|
class BulletBlock(BaseModel):
|
||||||
|
"""The data required for creating a block text."""
|
||||||
|
|
||||||
|
element_type: Literal["bullet_block"] = "bullet_block"
|
||||||
|
bullets: list[HtmlFragment]
|
||||||
|
"""A list of text elements (can include basic HTML tags)."""
|
||||||
|
|
||||||
|
|
||||||
class ActionButton(BaseModel):
|
class ActionButton(BaseModel):
|
||||||
"""The data required for creating an actionButton in Vue.
|
"""The data required for creating an actionButton in Vue.
|
||||||
|
|
@ -13,13 +157,15 @@ class ActionButton(BaseModel):
|
||||||
Currently this cannot be used to submitData, or to submitOnEvent.
|
Currently this cannot be used to submitData, or to submitOnEvent.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
element_type: Literal["action_button"] = "action_button"
|
||||||
|
|
||||||
thing: str
|
thing: str
|
||||||
"""The Thing "path" for the Thing instance."""
|
"""The Thing "path" for the Thing instance."""
|
||||||
|
|
||||||
action: str
|
action: str
|
||||||
"""The name of the action to be triggered."""
|
"""The name of the action to be triggered."""
|
||||||
|
|
||||||
poll_interval: int = 1
|
poll_interval: float = 1
|
||||||
"""The interval for polling in seconds."""
|
"""The interval for polling in seconds."""
|
||||||
|
|
||||||
submit_label: str = "Submit"
|
submit_label: str = "Submit"
|
||||||
|
|
@ -37,6 +183,9 @@ class ActionButton(BaseModel):
|
||||||
button_primary: bool = True
|
button_primary: bool = True
|
||||||
"""Specify whether the button is styled as a primary button."""
|
"""Specify whether the button is styled as a primary button."""
|
||||||
|
|
||||||
|
disabled: bool = False
|
||||||
|
"""Specify whether the button is disabled."""
|
||||||
|
|
||||||
modal_progress: bool = False
|
modal_progress: bool = False
|
||||||
"""Specify whether to show a progress modal."""
|
"""Specify whether to show a progress modal."""
|
||||||
|
|
||||||
|
|
@ -50,9 +199,21 @@ class ActionButton(BaseModel):
|
||||||
notify_on_success: bool = False
|
notify_on_success: bool = False
|
||||||
"""Specify whether to a notification on successful completion."""
|
"""Specify whether to a notification on successful completion."""
|
||||||
|
|
||||||
|
response_is_success_message: bool = False
|
||||||
|
"""Set True to use the action response as the success message.
|
||||||
|
|
||||||
|
If ``success_message`` is set, this is never used.
|
||||||
|
"""
|
||||||
|
|
||||||
success_message: str = "Success!"
|
success_message: str = "Success!"
|
||||||
"""The message to show on successful completion."""
|
"""The message to show on successful completion."""
|
||||||
|
|
||||||
|
update_interface_on_response: bool = False
|
||||||
|
"""If True the action button emits a requestUpdate signal when completed.
|
||||||
|
|
||||||
|
Downstream components can subscribe to this and request an update.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def action_button_for(thing: lt.Thing, action_name: str, **kwargs: Any) -> ActionButton:
|
def action_button_for(thing: lt.Thing, action_name: str, **kwargs: Any) -> ActionButton:
|
||||||
"""Create a ActionButton data for the specified Thing Action.
|
"""Create a ActionButton data for the specified Thing Action.
|
||||||
|
|
@ -72,6 +233,8 @@ class PropertyControl(BaseModel):
|
||||||
Currently this cannot be used to submitData, or to submitOnEvent.
|
Currently this cannot be used to submitData, or to submitOnEvent.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
element_type: Literal["property_control"] = "property_control"
|
||||||
|
|
||||||
thing: str
|
thing: str
|
||||||
"""The Thing "path" for the Thing instance."""
|
"""The Thing "path" for the Thing instance."""
|
||||||
|
|
||||||
|
|
@ -121,3 +284,63 @@ def property_control_for(
|
||||||
if "label" not in kwargs:
|
if "label" not in kwargs:
|
||||||
kwargs["label"] = property_name
|
kwargs["label"] = property_name
|
||||||
return PropertyControl(thing=thing.name, property_name=property_name, **kwargs)
|
return PropertyControl(thing=thing.name, property_name=property_name, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class Accordion(BaseModel):
|
||||||
|
"""The data required for creating an accordion in the UI.
|
||||||
|
|
||||||
|
The HTML is limited to a small number of tags.
|
||||||
|
"""
|
||||||
|
|
||||||
|
element_type: Literal["accordion"] = "accordion"
|
||||||
|
title: str
|
||||||
|
children: "UIElementList"
|
||||||
|
|
||||||
|
|
||||||
|
class Container(BaseModel):
|
||||||
|
"""The data required for creating ``<div>`` in the UI.
|
||||||
|
|
||||||
|
The HTML is limited to a small number of tags.
|
||||||
|
"""
|
||||||
|
|
||||||
|
element_type: Literal["container"] = "container"
|
||||||
|
css_class: str
|
||||||
|
children: "UIElementList"
|
||||||
|
|
||||||
|
|
||||||
|
UIElementModels = (
|
||||||
|
HeaderBlock
|
||||||
|
| TextBlock
|
||||||
|
| BulletBlock
|
||||||
|
| PropertyControl
|
||||||
|
| ActionButton
|
||||||
|
| Accordion
|
||||||
|
| Container
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UIElementList(RootModel[list[UIElementModels]]):
|
||||||
|
"""A list of user interface elements.
|
||||||
|
|
||||||
|
Note! Two important things to consider:
|
||||||
|
|
||||||
|
* This cannot be the return of a lt.property. To fully describe the UI recursion
|
||||||
|
is used. Web of Things DataSchema doesn't support recursion. Use an
|
||||||
|
lt.endpoint instead
|
||||||
|
* If the module using this class imports future annotations then this will cause
|
||||||
|
havoc with pydantics forward references.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Resolve forward references so Pydantic can build a sensible recursive schema
|
||||||
|
Accordion.model_rebuild()
|
||||||
|
Container.model_rebuild()
|
||||||
|
UIElementList.model_rebuild()
|
||||||
|
|
||||||
|
# This constant can be used as the value for the `responses` argument of lt.endpoint.
|
||||||
|
UI_ELEMENT_RESPONSE = {
|
||||||
|
200: {
|
||||||
|
"description": "A list of UI element specifications.",
|
||||||
|
"model": UIElementList,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
109
tests/shared_utils/md_test_cases.py
Normal file
109
tests/shared_utils/md_test_cases.py
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
"""Read multi-line test cases from markdown files.
|
||||||
|
|
||||||
|
When checking if text coercion is working multiline Python can be very difficult
|
||||||
|
to read and maintain. This file allows the input and expected results to be written
|
||||||
|
into markdown codeblocks.
|
||||||
|
|
||||||
|
Each test is a second level heading.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Level 2 markdown heading. Group 1 is the text
|
||||||
|
CASE_REGEX = re.compile(r"^##\s(.*)", re.MULTILINE)
|
||||||
|
# Level 3 markdown heading. Group 1 is the text
|
||||||
|
LEVEL3_REGEX = re.compile(r"^###\s(.*)", re.MULTILINE)
|
||||||
|
# Markdown code block.
|
||||||
|
# Group 1 is the language specification after the back ticks
|
||||||
|
# Group 2 is the code
|
||||||
|
CODE_REGEX = re.compile(
|
||||||
|
r"^```([a-zA-Z]+)?\s?\n(?:(.*?)\n)?```", re.MULTILINE | re.DOTALL
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MdTestCase:
|
||||||
|
"""A class to represent a test case read from a markdown file.
|
||||||
|
|
||||||
|
Useful attributes:
|
||||||
|
|
||||||
|
* title - The title of the test case
|
||||||
|
* input_code - the input code for the test case
|
||||||
|
* result_code - the expected result code for the test case
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, title: str, case_text: str, block_type: str) -> None:
|
||||||
|
"""Initialise the test case.
|
||||||
|
|
||||||
|
:param title: The title of the test case.
|
||||||
|
:param case_text: The full text of the test case.
|
||||||
|
:param block_type: The type of code block expected (the label after the triple
|
||||||
|
backticks).
|
||||||
|
"""
|
||||||
|
self.title = title
|
||||||
|
self.block_type = block_type
|
||||||
|
self.input_code, self.result_code = self._parse(case_text)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
"""Return the string representation of the test case."""
|
||||||
|
return f"<MdTestCase: {self.title}>"
|
||||||
|
|
||||||
|
def _parse(self, case_text: str) -> tuple[str, str]:
|
||||||
|
"""Parse the case text and return the input and expected return code."""
|
||||||
|
h3_matches = list(LEVEL3_REGEX.finditer(case_text))
|
||||||
|
n_h3_matches = len(h3_matches)
|
||||||
|
if n_h3_matches != 2:
|
||||||
|
raise ValueError(
|
||||||
|
f"Case {self.title} has {n_h3_matches} level 3 headings. 2 expected."
|
||||||
|
)
|
||||||
|
if h3_matches[0].group(1) != "Input" or h3_matches[1].group(1) != "Result":
|
||||||
|
raise ValueError(
|
||||||
|
f"Case {self.title} does not have `Input` and `Result` headings."
|
||||||
|
)
|
||||||
|
|
||||||
|
input_section = case_text[h3_matches[0].end() : h3_matches[1].start()]
|
||||||
|
result_section = case_text[h3_matches[1].end() : None]
|
||||||
|
|
||||||
|
input_code_blocks = list(CODE_REGEX.finditer(input_section))
|
||||||
|
result_code_blocks = list(CODE_REGEX.finditer(result_section))
|
||||||
|
if len(input_code_blocks) != 1 or len(result_code_blocks) != 1:
|
||||||
|
raise ValueError(
|
||||||
|
f"Case {self.title} must have exactly 1 code block per section."
|
||||||
|
)
|
||||||
|
|
||||||
|
input_code_type = input_code_blocks[0].group(1)
|
||||||
|
input_code = input_code_blocks[0].group(2)
|
||||||
|
result_code_type = result_code_blocks[0].group(1)
|
||||||
|
result_code = result_code_blocks[0].group(2)
|
||||||
|
if input_code_type != self.block_type or result_code_type != self.block_type:
|
||||||
|
raise ValueError(
|
||||||
|
f"Case {self.title}: each code block must be labeled as "
|
||||||
|
f"{self.block_type}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# If the code block is empty Regex returns None. Coerce to empty string.
|
||||||
|
input_code = "" if input_code is None else input_code.strip()
|
||||||
|
result_code = "" if result_code is None else result_code.strip()
|
||||||
|
return input_code, result_code
|
||||||
|
|
||||||
|
|
||||||
|
def find_test_cases(path: str, block_type: str) -> list[MdTestCase]:
|
||||||
|
"""Return all test cases in the markdown file.
|
||||||
|
|
||||||
|
:param path: Path to the markdown file containing cases.
|
||||||
|
:param block_type: The label for each triple backtick code block.
|
||||||
|
|
||||||
|
:return: A list of MdTestCase objects for each case.
|
||||||
|
"""
|
||||||
|
with open(path, "r", encoding="utf-8") as file_obj:
|
||||||
|
text = file_obj.read()
|
||||||
|
test_case_headings = list(CASE_REGEX.finditer(text))
|
||||||
|
test_cases = []
|
||||||
|
n_headings = len(test_case_headings)
|
||||||
|
for i, heading_match in enumerate(test_case_headings):
|
||||||
|
title = heading_match.group(1)
|
||||||
|
start = heading_match.end()
|
||||||
|
end = test_case_headings[i + 1].start() if i + 1 < n_headings else None
|
||||||
|
|
||||||
|
case_text = text[start:end]
|
||||||
|
test_cases.append(MdTestCase(title, case_text, block_type))
|
||||||
|
return test_cases
|
||||||
125
tests/unit_tests/data/html_sanitisation_test_cases.md
Normal file
125
tests/unit_tests/data/html_sanitisation_test_cases.md
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# HTML Sanitisation Test Cases
|
||||||
|
|
||||||
|
This file is in markdown not Python for readability.
|
||||||
|
|
||||||
|
Each test is a level 2 heading. The input and expected values are level 3 headings. Multiline inputs and results are treated as single, multiline strings.
|
||||||
|
|
||||||
|
The HTML to test is in triple backtick code blocks.
|
||||||
|
|
||||||
|
Anything not in code blocks is a comment.
|
||||||
|
|
||||||
|
This file is run by /tests/unit_tests/test_ui.py
|
||||||
|
|
||||||
|
## Test scripts removed
|
||||||
|
The script is removed but the contents remain.
|
||||||
|
### Input
|
||||||
|
```html
|
||||||
|
<script>alert("x")</script>
|
||||||
|
<p>Hello</p>
|
||||||
|
```
|
||||||
|
### Result
|
||||||
|
```html
|
||||||
|
alert("x")
|
||||||
|
Hello
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Test script in bold tag
|
||||||
|
### Input
|
||||||
|
```html
|
||||||
|
<b onmouseover=alert('Wufff!')>click me!</b>
|
||||||
|
```
|
||||||
|
### Result
|
||||||
|
```html
|
||||||
|
<b>click me!</b>
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Test script in disallowed tag
|
||||||
|
### Input
|
||||||
|
```html
|
||||||
|
<img src="http://url.to.file.which/not.exist" onerror=alert(document.cookie);>
|
||||||
|
```
|
||||||
|
### Result
|
||||||
|
```html
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Test cookie grabber script is escaped
|
||||||
|
The script should become escaped and have no tags
|
||||||
|
### Input
|
||||||
|
```html
|
||||||
|
<SCRIPT type="text/javascript">
|
||||||
|
var adr = '../evil.php?cakemonster=' + escape(document.cookie);
|
||||||
|
</SCRIPT>
|
||||||
|
```
|
||||||
|
### Result
|
||||||
|
```html
|
||||||
|
var adr = '../evil.php?cakemonster=' + escape(document.cookie);
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Test with non-http links
|
||||||
|
Remove the javascript from the links.
|
||||||
|
### Input
|
||||||
|
```html
|
||||||
|
<a href="javascript:alert(1)">link</a>
|
||||||
|
<a href="javascript:alert(1)">encoded</a>
|
||||||
|
<a href="mailto:spammy@spam.com">Email</a>
|
||||||
|
<a href="/a/relateive/link">Relative</a>
|
||||||
|
```
|
||||||
|
### Result
|
||||||
|
```html
|
||||||
|
<a>link</a>
|
||||||
|
<a>encoded</a>
|
||||||
|
<a>Email</a>
|
||||||
|
<a>Relative</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Test http links become external
|
||||||
|
Remove the javascript from the links.
|
||||||
|
### Input
|
||||||
|
```html
|
||||||
|
<a href="https://openflexure.org">OpenFlexure</a>
|
||||||
|
```
|
||||||
|
### Result
|
||||||
|
```html
|
||||||
|
<a href="https://openflexure.org" target="_blank" rel="noopener noreferrer">OpenFlexure</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Test basic formatting preserved
|
||||||
|
Check basic elements are not changed
|
||||||
|
### Input
|
||||||
|
```html
|
||||||
|
This is <b>bold</b> <i>italic</i> and on a new <br/> line.
|
||||||
|
```
|
||||||
|
### Result
|
||||||
|
```html
|
||||||
|
This is <b>bold</b> <i>italic</i> and on a new <br/> line.
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Test http link with path preserved
|
||||||
|
Valid HTTP links should preserve their path.
|
||||||
|
### Input
|
||||||
|
```html
|
||||||
|
<a href="https://example.com/docs/page">Docs</a>
|
||||||
|
```
|
||||||
|
### Result
|
||||||
|
```html
|
||||||
|
<a href="https://example.com/docs/page" target="_blank" rel="noopener noreferrer">Docs</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Test http link with fragment preserved
|
||||||
|
Valid HTTP links should preserve their path and fragment.
|
||||||
|
### Input
|
||||||
|
```html
|
||||||
|
<a href="https://example.com/docs/page#section">Docs Section</a>
|
||||||
|
```
|
||||||
|
### Result
|
||||||
|
```html
|
||||||
|
<a href="https://example.com/docs/page#section" target="_blank" rel="noopener noreferrer">Docs Section</a>
|
||||||
|
```
|
||||||
|
|
@ -73,7 +73,7 @@ def test_partial_base_classes():
|
||||||
bad_algo.ready
|
bad_algo.ready
|
||||||
|
|
||||||
with pytest.raises(NotImplementedError):
|
with pytest.raises(NotImplementedError):
|
||||||
bad_algo.settings_ui
|
bad_algo.settings_ui()
|
||||||
|
|
||||||
with pytest.raises(NotImplementedError):
|
with pytest.raises(NotImplementedError):
|
||||||
bad_algo.set_background(background_image)
|
bad_algo.set_background(background_image)
|
||||||
|
|
@ -256,7 +256,7 @@ def test_background_detect_settings_ui(detector_cls):
|
||||||
As both have identical settings they can be tested together
|
As both have identical settings they can be tested together
|
||||||
"""
|
"""
|
||||||
detector = create_thing_without_server(detector_cls)
|
detector = create_thing_without_server(detector_cls)
|
||||||
ui = detector.settings_ui
|
ui = detector.settings_ui().root
|
||||||
|
|
||||||
assert len(ui) == 2
|
assert len(ui) == 2
|
||||||
assert isinstance(ui[0], PropertyControl)
|
assert isinstance(ui[0], PropertyControl)
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,14 @@ from openflexure_microscope_server.things.scan_workflows import (
|
||||||
HistoScanWorkflow,
|
HistoScanWorkflow,
|
||||||
ScanWorkflow,
|
ScanWorkflow,
|
||||||
)
|
)
|
||||||
from openflexure_microscope_server.ui import PropertyControl
|
from openflexure_microscope_server.ui import (
|
||||||
|
Accordion,
|
||||||
|
ActionButton,
|
||||||
|
HeaderBlock,
|
||||||
|
PropertyControl,
|
||||||
|
TextBlock,
|
||||||
|
UIElementList,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_partial_base_classes():
|
def test_partial_base_classes():
|
||||||
|
|
@ -56,7 +63,7 @@ def test_partial_base_classes():
|
||||||
bad_workflow.acquisition_routine(settings, xyz_pos=[0, 0, 0])
|
bad_workflow.acquisition_routine(settings, xyz_pos=[0, 0, 0])
|
||||||
|
|
||||||
with pytest.raises(NotImplementedError):
|
with pytest.raises(NotImplementedError):
|
||||||
bad_workflow.settings_ui
|
bad_workflow.settings_ui()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -370,15 +377,49 @@ def test_histo_acquisition_on_sample(histo_workflow, mocker, caplog):
|
||||||
assert histo_workflow._autofocus.run_smart_stack.call_count == 2
|
assert histo_workflow._autofocus.run_smart_stack.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
def test_histo_workflow_settings_ui(histo_workflow):
|
def test_histo_workflow_settings_ui(histo_workflow, mocker):
|
||||||
"""Check that the workflow specifies the expected controls."""
|
"""Check that the workflow specifies the expected controls."""
|
||||||
ui = histo_workflow.settings_ui
|
mock_prop_control = mocker.Mock(spec=PropertyControl)
|
||||||
|
histo_workflow._background_detector.settings_ui.return_value = UIElementList(
|
||||||
|
[mock_prop_control]
|
||||||
|
)
|
||||||
|
|
||||||
assert len(ui) == 8
|
ui = histo_workflow.settings_ui().root
|
||||||
for element in ui:
|
|
||||||
|
# The UI is in 4 blocks
|
||||||
|
assert len(ui) == 4
|
||||||
|
# The top is a header
|
||||||
|
assert isinstance(ui[0], HeaderBlock)
|
||||||
|
assert ui[0].text == "Histo Scan"
|
||||||
|
# The then some text (note the & is escaped)
|
||||||
|
assert isinstance(ui[1], TextBlock)
|
||||||
|
fragment = "This scan workflow is optimised for scanning H&E stained biopsies."
|
||||||
|
assert fragment in ui[1].text
|
||||||
|
# Followed by a background detect accordion
|
||||||
|
assert isinstance(ui[2], Accordion)
|
||||||
|
assert ui[2].title == "Background Detect"
|
||||||
|
# And a Scan Settings accordion
|
||||||
|
assert isinstance(ui[3], Accordion)
|
||||||
|
assert ui[3].title == "Scan Settings"
|
||||||
|
|
||||||
|
# Background UI is ...
|
||||||
|
background_ui = ui[2].children.root
|
||||||
|
# what the detector specifies as it settings ...
|
||||||
|
assert background_ui[0] is mock_prop_control
|
||||||
|
# plus 2 action buttons
|
||||||
|
assert len(background_ui) == 3
|
||||||
|
assert isinstance(background_ui[1], ActionButton)
|
||||||
|
assert background_ui[1].action == "set_background"
|
||||||
|
assert isinstance(background_ui[2], ActionButton)
|
||||||
|
assert background_ui[2].action == "check_background"
|
||||||
|
|
||||||
|
# Finally check the contents of the scan settings accordion
|
||||||
|
scan_settings = ui[3].children.root
|
||||||
|
assert len(scan_settings) == 8
|
||||||
|
for element in scan_settings:
|
||||||
assert isinstance(element, PropertyControl)
|
assert isinstance(element, PropertyControl)
|
||||||
|
|
||||||
names = [el.property_name for el in ui]
|
names = [el.property_name for el in scan_settings]
|
||||||
expected_names = [
|
expected_names = [
|
||||||
"overlap",
|
"overlap",
|
||||||
"stack_images_to_save",
|
"stack_images_to_save",
|
||||||
|
|
|
||||||
24
tests/unit_tests/test_ui.py
Normal file
24
tests/unit_tests/test_ui.py
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
"""Testing the server specified user interface."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from openflexure_microscope_server import ui
|
||||||
|
|
||||||
|
from ..shared_utils.md_test_cases import find_test_cases
|
||||||
|
|
||||||
|
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
HTML_SANITISATION_TEST_FILE = os.path.join(
|
||||||
|
THIS_DIR, "data", "html_sanitisation_test_cases.md"
|
||||||
|
)
|
||||||
|
HTML_SANITISATION_TEST_CASES = find_test_cases(HTML_SANITISATION_TEST_FILE, "html")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("test_case", HTML_SANITISATION_TEST_CASES)
|
||||||
|
def test_html_block_sanitises(test_case):
|
||||||
|
"""Check each case in the test case file sanitises as expected."""
|
||||||
|
sanitised = ui.sanitise_html(test_case.input_code).strip()
|
||||||
|
assert sanitised == test_case.result_code, (
|
||||||
|
f"Test case: '{test_case.title}': Input code doesn't sanitise as expected."
|
||||||
|
)
|
||||||
|
|
@ -82,7 +82,6 @@ import tabContent from "./genericComponents/tabContent.vue";
|
||||||
|
|
||||||
// Import new content components
|
// Import new content components
|
||||||
import aboutContent from "./tabContentComponents/aboutContent.vue";
|
import aboutContent from "./tabContentComponents/aboutContent.vue";
|
||||||
import backgroundDetectContent from "./tabContentComponents/backgroundDetectContent.vue";
|
|
||||||
import controlContent from "./tabContentComponents/controlContent.vue";
|
import controlContent from "./tabContentComponents/controlContent.vue";
|
||||||
import loggingContent from "./tabContentComponents/loggingContent.vue";
|
import loggingContent from "./tabContentComponents/loggingContent.vue";
|
||||||
import powerContent from "./tabContentComponents/powerContent.vue";
|
import powerContent from "./tabContentComponents/powerContent.vue";
|
||||||
|
|
@ -150,15 +149,6 @@ export default {
|
||||||
component: markRaw(controlContent),
|
component: markRaw(controlContent),
|
||||||
requiredThings: [],
|
requiredThings: [],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "background-detect",
|
|
||||||
title: "Background Detect",
|
|
||||||
icon: "background_replace",
|
|
||||||
component: markRaw(backgroundDetectContent),
|
|
||||||
// While stage isn't needed; automatic background detect has little function
|
|
||||||
// for a manual microscope.
|
|
||||||
requiredThings: ["stage"],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "slide-scan",
|
id: "slide-scan",
|
||||||
title: "Slide Scan",
|
title: "Slide Scan",
|
||||||
|
|
|
||||||
23
webapp/src/components/genericComponents/simpleAccordion.vue
Normal file
23
webapp/src/components/genericComponents/simpleAccordion.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
<template>
|
||||||
|
<ul uk-accordion="multiple: true">
|
||||||
|
<li>
|
||||||
|
<a class="uk-accordion-title" href="#">{{ title }}</a>
|
||||||
|
<div class="uk-accordion-content">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "SimpleAccordion",
|
||||||
|
|
||||||
|
props: {
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -322,6 +322,9 @@ export default {
|
||||||
} else if (response.data.status == "cancelled") {
|
} else if (response.data.status == "cancelled") {
|
||||||
this.$emit("cancelled", response.data);
|
this.$emit("cancelled", response.data);
|
||||||
this.modalNotify(`The action '${this.submitLabel}' was cancelled.`);
|
this.modalNotify(`The action '${this.submitLabel}' was cancelled.`);
|
||||||
|
} else if (response.data.status == "error") {
|
||||||
|
const err_msg = this.log?.[0]?.message ?? "Unknown error";
|
||||||
|
this.$emit("error", err_msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.taskUrl = null;
|
this.taskUrl = null;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
:requires-confirmation="actionData.requires_confirmation"
|
:requires-confirmation="actionData.requires_confirmation"
|
||||||
:confirmation-message="actionData.confirmation_message"
|
:confirmation-message="actionData.confirmation_message"
|
||||||
:button-primary="actionData.button_primary"
|
:button-primary="actionData.button_primary"
|
||||||
|
:is-disabled="actionData.disabled"
|
||||||
:modal-progress="actionData.modal_progress"
|
:modal-progress="actionData.modal_progress"
|
||||||
:stream-with-modal="actionData.stream_with_modal"
|
:stream-with-modal="actionData.stream_with_modal"
|
||||||
@response="actionResponse"
|
@response="actionResponse"
|
||||||
|
|
@ -34,7 +35,7 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
emits: ["response", "finished"],
|
emits: ["response", "finished", "requestUpdate"],
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
/**
|
/**
|
||||||
|
|
@ -45,7 +46,14 @@ export default {
|
||||||
*/
|
*/
|
||||||
actionResponse: function (response) {
|
actionResponse: function (response) {
|
||||||
if (this.actionData.notify_on_success) {
|
if (this.actionData.notify_on_success) {
|
||||||
this.modalNotify(this.actionData.success_message);
|
if (this.actionData.response_is_success_message) {
|
||||||
|
this.modalNotify(response.output);
|
||||||
|
} else {
|
||||||
|
this.modalNotify(this.actionData.success_message);
|
||||||
|
}
|
||||||
|
if (this.actionData.update_interface_on_response) {
|
||||||
|
this.$emit("requestUpdate");
|
||||||
|
}
|
||||||
this.$emit("response", response);
|
this.$emit("response", response);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
<template>
|
||||||
|
<div v-for="(element, index) in elements" :key="index" class="uk-margin">
|
||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<component :is="'h' + element.level" v-if="element.element_type === 'header_block'">
|
||||||
|
<span v-html="element.text"></span>
|
||||||
|
</component>
|
||||||
|
<p v-if="element.element_type === 'text_block'" v-html="element.text"></p>
|
||||||
|
<ul v-if="element.element_type === 'bullet_block'">
|
||||||
|
<li
|
||||||
|
v-for="(bulletText, bulletIndex) in element.bullets"
|
||||||
|
:key="'bullet-' + bulletIndex"
|
||||||
|
v-html="bulletText"
|
||||||
|
></li>
|
||||||
|
</ul>
|
||||||
|
<!-- eslint-enable -->
|
||||||
|
<server-specified-property-control
|
||||||
|
v-else-if="element.element_type === 'property_control'"
|
||||||
|
:property-data="element"
|
||||||
|
/>
|
||||||
|
<server-specified-action-button
|
||||||
|
v-else-if="element.element_type === 'action_button'"
|
||||||
|
:action-data="element"
|
||||||
|
@request-update="$emit('requestUpdate')"
|
||||||
|
/>
|
||||||
|
<simple-accordion v-else-if="element.element_type === 'accordion'" :title="element.title">
|
||||||
|
<server-specified-interface
|
||||||
|
:elements="element.children"
|
||||||
|
@request-update="$emit('requestUpdate')"
|
||||||
|
/>
|
||||||
|
</simple-accordion>
|
||||||
|
<div v-else-if="element.element_type === 'container'" :class="element.css_class">
|
||||||
|
<server-specified-interface
|
||||||
|
:elements="element.children"
|
||||||
|
@request-update="$emit('requestUpdate')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import SimpleAccordion from "../genericComponents/simpleAccordion.vue";
|
||||||
|
import ServerSpecifiedPropertyControl from "./serverSpecifiedPropertyControl.vue";
|
||||||
|
import ServerSpecifiedActionButton from "./serverSpecifiedActionButton.vue";
|
||||||
|
|
||||||
|
// Export main app
|
||||||
|
export default {
|
||||||
|
name: "ServerSpecifiedInterface",
|
||||||
|
|
||||||
|
components: {
|
||||||
|
SimpleAccordion,
|
||||||
|
ServerSpecifiedPropertyControl,
|
||||||
|
ServerSpecifiedActionButton,
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
elements: {
|
||||||
|
type: Array,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
emits: ["requestUpdate"],
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less"></style>
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
<template>
|
|
||||||
<div ref="backgroundDetectContent" class="uk-padding-small">
|
|
||||||
<div>
|
|
||||||
<ul uk-accordion="multiple: true">
|
|
||||||
<li>
|
|
||||||
<a class="uk-accordion-title" href="#">Configure</a>
|
|
||||||
<div class="uk-accordion-content">
|
|
||||||
<h4 v-if="backgroundDetectorDisplayName" class="detector-name">
|
|
||||||
{{ backgroundDetectorDisplayName }}
|
|
||||||
</h4>
|
|
||||||
<server-specified-property-control
|
|
||||||
v-for="(setting, index) in backgroundDetectorSettings"
|
|
||||||
:key="'detector_setting' + index"
|
|
||||||
:property-data="setting"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<div class="uk-margin">
|
|
||||||
<action-button
|
|
||||||
thing="camera"
|
|
||||||
action="set_background"
|
|
||||||
submit-label="Set Background"
|
|
||||||
:can-terminate="false"
|
|
||||||
:poll-interval="0.1"
|
|
||||||
@response="alertBackgroundSet"
|
|
||||||
@error="modalError"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="uk-margin">
|
|
||||||
<!--Once status is read this should be disabled if not ready..-->
|
|
||||||
<action-button
|
|
||||||
thing="camera"
|
|
||||||
action="image_is_sample"
|
|
||||||
submit-label="Check Current Image"
|
|
||||||
:is-disabled="!ready"
|
|
||||||
:can-terminate="false"
|
|
||||||
:poll-interval="0.1"
|
|
||||||
@response="alertImageLabel"
|
|
||||||
@error="modalError"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import ActionButton from "../../labThingsComponents/actionButton.vue";
|
|
||||||
import ServerSpecifiedPropertyControl from "../../labThingsComponents/serverSpecifiedPropertyControl.vue";
|
|
||||||
import { useIntersectionObserver } from "@vueuse/core";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
ActionButton,
|
|
||||||
ServerSpecifiedPropertyControl,
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
ready: false,
|
|
||||||
backgroundDetectorName: undefined,
|
|
||||||
backgroundDetectorDisplayName: undefined,
|
|
||||||
backgroundDetectorSettings: [],
|
|
||||||
animate: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async created() {
|
|
||||||
await this.readSettings();
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
useIntersectionObserver(
|
|
||||||
this.$refs.backgroundDetectContent,
|
|
||||||
([{ isIntersecting }]) => {
|
|
||||||
this.visibilityChanged(isIntersecting);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
threshold: 0.0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
async safeReadSettings() {
|
|
||||||
if (!this.$store.state.connected) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.readSettings();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error reading background detector settings:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
visibilityChanged(isVisible) {
|
|
||||||
if (isVisible) {
|
|
||||||
this.safeReadSettings();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
alertBackgroundSet() {
|
|
||||||
this.modalNotify(`Background image has been updated`);
|
|
||||||
},
|
|
||||||
alertImageLabel(r) {
|
|
||||||
let label = r.output[0] ? "sample" : "background";
|
|
||||||
this.modalNotify(`Current image is ${label} (${r.output[1]})`);
|
|
||||||
},
|
|
||||||
readSettings: async function () {
|
|
||||||
this.backgroundDetectorName = await this.readThingProperty(
|
|
||||||
"camera",
|
|
||||||
"background_detector_name",
|
|
||||||
);
|
|
||||||
if (this.backgroundDetectorName) {
|
|
||||||
this.ready = await this.readThingProperty(this.backgroundDetectorName, "ready");
|
|
||||||
this.backgroundDetectorSettings = await this.readThingProperty(
|
|
||||||
this.backgroundDetectorName,
|
|
||||||
"settings_ui",
|
|
||||||
);
|
|
||||||
this.backgroundDetectorDisplayName = await this.readThingProperty(
|
|
||||||
this.backgroundDetectorName,
|
|
||||||
"display_name",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<style scoped lang="less">
|
|
||||||
.detector-name {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
<template>
|
|
||||||
<!-- Grid managing tab content -->
|
|
||||||
<div uk-grid class="uk-height-1-1 uk-margin-remove uk-padding-remove">
|
|
||||||
<div class="control-component">
|
|
||||||
<paneBackgroundDetect />
|
|
||||||
</div>
|
|
||||||
<div class="view-component uk-width-expand">
|
|
||||||
<streamDisplay />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import paneBackgroundDetect from "./backgroundDetectComponents/paneBackgroundDetect";
|
|
||||||
import streamDisplay from "./streamContent.vue";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: "BackgroundDetectContent",
|
|
||||||
|
|
||||||
components: {
|
|
||||||
paneBackgroundDetect,
|
|
||||||
streamDisplay,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
@ -16,23 +16,8 @@
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<h4 v-if="workflowDisplayName" class="workflow-name">
|
<server-specified-interface :elements="workflowSettings" @request-update="readSettings" />
|
||||||
{{ workflowDisplayName }}
|
|
||||||
</h4>
|
|
||||||
<p class="workflow-blurb">{{ workflowBlurb }}</p>
|
|
||||||
<ul uk-accordion="multiple: true">
|
<ul uk-accordion="multiple: true">
|
||||||
<li>
|
|
||||||
<a class="uk-accordion-title" href="#">Scan Settings</a>
|
|
||||||
<div class="uk-accordion-content">
|
|
||||||
<div
|
|
||||||
v-for="(setting, index) in workflowSettings"
|
|
||||||
:key="'detector_setting' + index"
|
|
||||||
class="uk-margin"
|
|
||||||
>
|
|
||||||
<server-specified-property-control :property-data="setting" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li class="uk-open">
|
<li class="uk-open">
|
||||||
<a class="uk-accordion-title" href="#">Stitching Settings</a>
|
<a class="uk-accordion-title" href="#">Stitching Settings</a>
|
||||||
<div class="uk-accordion-content">
|
<div class="uk-accordion-content">
|
||||||
|
|
@ -125,7 +110,7 @@
|
||||||
<script>
|
<script>
|
||||||
import streamDisplay from "./streamContent.vue";
|
import streamDisplay from "./streamContent.vue";
|
||||||
import propertyControl from "../labThingsComponents/propertyControl.vue";
|
import propertyControl from "../labThingsComponents/propertyControl.vue";
|
||||||
import ServerSpecifiedPropertyControl from "../labThingsComponents/serverSpecifiedPropertyControl.vue";
|
import ServerSpecifiedInterface from "../labThingsComponents/serverSpecifiedInterface.vue";
|
||||||
import actionLogDisplay from "../labThingsComponents/actionLogDisplay.vue";
|
import actionLogDisplay from "../labThingsComponents/actionLogDisplay.vue";
|
||||||
import actionProgressBar from "../labThingsComponents/actionProgressBar.vue";
|
import actionProgressBar from "../labThingsComponents/actionProgressBar.vue";
|
||||||
import MiniStreamDisplay from "../genericComponents/miniStreamDisplay.vue";
|
import MiniStreamDisplay from "../genericComponents/miniStreamDisplay.vue";
|
||||||
|
|
@ -142,7 +127,7 @@ export default {
|
||||||
actionProgressBar,
|
actionProgressBar,
|
||||||
MiniStreamDisplay,
|
MiniStreamDisplay,
|
||||||
ActionButton,
|
ActionButton,
|
||||||
ServerSpecifiedPropertyControl,
|
ServerSpecifiedInterface,
|
||||||
},
|
},
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
|
|
@ -158,8 +143,6 @@ export default {
|
||||||
scan_name: "",
|
scan_name: "",
|
||||||
workflowName: undefined,
|
workflowName: undefined,
|
||||||
workflowSettings: [],
|
workflowSettings: [],
|
||||||
workflowDisplayName: undefined,
|
|
||||||
workflowBlurb: undefined,
|
|
||||||
workflowOptions: [],
|
workflowOptions: [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
@ -211,13 +194,7 @@ export default {
|
||||||
if (this.workflowName) {
|
if (this.workflowName) {
|
||||||
this.ready = await this.readThingProperty(this.workflowName, "ready", true);
|
this.ready = await this.readThingProperty(this.workflowName, "ready", true);
|
||||||
this.workflowSettings =
|
this.workflowSettings =
|
||||||
(await this.readThingProperty(this.workflowName, "settings_ui", true)) || [];
|
(await this.getThingEndpoint(this.workflowName, "settings_ui")) || [];
|
||||||
this.workflowDisplayName = await this.readThingProperty(
|
|
||||||
this.workflowName,
|
|
||||||
"display_name",
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
this.workflowBlurb = await this.readThingProperty(this.workflowName, "ui_blurb", true);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onScanError: function (error) {
|
onScanError: function (error) {
|
||||||
|
|
@ -296,10 +273,4 @@ export default {
|
||||||
.control-component {
|
.control-component {
|
||||||
width: 33%;
|
width: 33%;
|
||||||
}
|
}
|
||||||
.workflow-name {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
.workflow-blurb {
|
|
||||||
color: #a2a2a2;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -135,5 +135,14 @@ export default {
|
||||||
);
|
);
|
||||||
return url;
|
return url;
|
||||||
},
|
},
|
||||||
|
async getThingEndpoint(thing, endpoint) {
|
||||||
|
let url = `${this.$store.getters.baseUri}/${thing}/${endpoint}`;
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url);
|
||||||
|
return response.data;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue