Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ node_modules
omero_biomero/group_mappings.json
/venv
omero_biomero/_version.py
file.db
file.db
/.idea
2 changes: 1 addition & 1 deletion omero-init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ CONTAINER_NAME="nl-biomero-omeroweb-1"
# COMMAND1="/usr/local/bin/entrypoint.sh"
COMMAND0="chmod a+w /opt/omero/web/OMERO.web/var/static"

COMMAND1="/opt/omero/web/venv3/bin/python -m pip install -e /opt/omero/web/OMERO.biomero"
COMMAND1="/opt/omero/web/venv3/bin/python -m pip install --no-cache-dir -e /opt/omero/web/OMERO.biomero --force-reinstall --no-deps ; /opt/omero/web/venv3/bin/python -m pip install --no-cache-dir --force-reinstall 'biomero-schema @ git+https://github.com/BioImageTools/biomero-schema.git@dev-bilayers' 'biomero @ git+https://github.com/NL-BioImaging/biomero.git@dev-bilayers'"
COMMAND2="/opt/omero/web/venv3/bin/omero-biomero-setup"

COMMAND3="/opt/omero/web/venv3/bin/omero web stop || true; rm -f /opt/omero/web/OMERO.web/var/django.pid"
Expand Down
349 changes: 286 additions & 63 deletions omero_biomero/analyzer_views.py

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"main.js": "/omero_biomero/assets/main.699499eae873f02794cc.js",
"main.js": "/omero_biomero/assets/main.bc363aff9500f001994b.js",
"blueprint-icons-all-paths-loader.js": "/omero_biomero/assets/main.842384bb9fbdafbc9d23.js",
"blueprint-icons-split-paths-by-size-loader.js": "/omero_biomero/assets/main.2402bf9c06063ed4b903.js",
"blueprint-icons-all-paths.js": "/omero_biomero/assets/main.14619d69ed529fa1589a.js",
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions omero_biomero/tests/test_analyzer_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def __enter__(self):
def __exit__(self, exc_type, exc, tb):
return False

def pull_descriptor_from_github(self, name):
def generic_descriptor_from_github(self, name):
return self._metadata.get(name, {})

@classmethod
Expand Down Expand Up @@ -164,7 +164,7 @@ def __enter__(self):
# --- get_workflow_metadata ---
def test_get_workflow_metadata_missing(self):
view = _raw("get_workflow_metadata")
request = SimpleNamespace(method="GET")
request = SimpleNamespace(method="GET", GET={}, headers={})
resp = view(request)
self.assertEqual(resp.status_code, 400)

Expand All @@ -174,7 +174,7 @@ class StubSlurm(self._StubSlurmBase):

with patch("omero_biomero.analyzer_views.SlurmClient", StubSlurm):
view = _raw("get_workflow_metadata")
request = SimpleNamespace(method="GET")
request = SimpleNamespace(method="GET", GET={}, headers={})
resp = view(request, name="wfA")
self.assertEqual(resp.status_code, 404)

Expand All @@ -187,7 +187,7 @@ class StubSlurm(self._StubSlurmBase):

with patch("omero_biomero.analyzer_views.SlurmClient", StubSlurm):
view = _raw("get_workflow_metadata")
request = SimpleNamespace(method="GET")
request = SimpleNamespace(method="GET", GET={}, headers={})
resp = view(request, name="wfA")
self.assertEqual(resp.status_code, 200)
data = json.loads(resp.content)
Expand Down
12 changes: 11 additions & 1 deletion omero_biomero/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
# Analyzer API under /api/analyzer/
path(
"api/analyzer/workflows/",
analyzer_views.list_workflows, # GET
analyzer_views.list_workflows, # GET: returns {"workflows": [name, ...]}
name="analyzer_workflows_list",
),
path(
Expand All @@ -50,6 +50,16 @@
analyzer_views.get_slurm_status, # GET: SLURM cluster status
name="analyzer_slurm_status",
),
path(
"api/analyzer/attachments/",
analyzer_views.get_attachments, # GET: browse OMERO file annotations
name="analyzer_attachments",
),
path(
"api/analyzer/object-annotations/",
analyzer_views.get_object_annotations, # GET: file annotations for one OMERO object
name="analyzer_object_annotations",
),
# Main Biomero URL
path(
"biomero/",
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def read(fname):
install_requires=[
"omero-web>=5.31.0,<6",
"pyjwt>=2.12.1,<3",
"biomero>=2.6,<3",
"biomero @ git+https://github.com/NL-BioImaging/biomero.git@dev-bilayers",
"configupdater>=3.2,<4",
"biomero-importer>=1.2.1,<2",
],
Expand Down
86 changes: 59 additions & 27 deletions webapp/src/AppContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ const INFRA_PARAMS = new Set([

const MAX_INPUT_IDS_SHOWN = 20;

const WorkflowSubmitToast = ({ workflowName, startedAt, params }) => {
// File-type param types (mirrors WorkflowFileInputStep)
const FILE_INPUT_TYPES_SET = new Set(["file", "array", "measurement", "executable"]);

const WorkflowSubmitToast = ({ workflowName, startedAt, params, metadata }) => {
const [openSection, setOpenSection] = React.useState(null);
const toggle = (key) => setOpenSection((prev) => (prev === key ? null : key));

Expand All @@ -54,9 +57,35 @@ const WorkflowSubmitToast = ({ workflowName, startedAt, params }) => {
if (params.attachToOriginalImages) outputLines.push("Attached to input images");
if (params.receiveEmail) outputLines.push("E-mail on completion");

const wfParams = Object.entries(params).filter(([k]) => !INFRA_PARAMS.has(k));
// Build lookup from descriptor so we can classify each param
const inputById = {};
(metadata?.inputs || []).forEach((inp) => { inputById[inp.id] = inp; });

// File attachment params (set-by-server + file type) → shown in Input section
const fileAttachmentEntries = Object.entries(params).filter(([k]) => {
const inp = inputById[k];
return inp && inp["set-by-server"] && FILE_INPUT_TYPES_SET.has(inp.type);
});

// True workflow params: not infra, not set-by-server, not output-dir-set.
// Falls back to old behaviour (show everything non-infra) when metadata is absent.
const wfParams = Object.entries(params).filter(([k]) => {
if (INFRA_PARAMS.has(k)) return false;
if (!metadata) return true;
const inp = inputById[k];
if (!inp) return false; // not in descriptor — skip
if (inp["set-by-server"]) return false; // handled elsewhere
if (inp["output-dir-set"]) return false; // internal dir — not user-facing
return true;
});

const inputCount = params.IDs?.length || 0;
const dataType = params.Data_Type || "Image";
// Count non-empty file attachment slots for the Input section label
const attachmentCount = fileAttachmentEntries.reduce((sum, [, v]) => {
const ids = Array.isArray(v) ? v.filter((x) => x != null && x !== "") : (v != null && v !== "" ? [v] : []);
return sum + ids.length;
}, 0);
const batchInfo = params.batchEnabled
? `${params.batchCount} jobs × ${params.batchSize} ${dataType}s`
: null;
Expand Down Expand Up @@ -87,15 +116,21 @@ const WorkflowSubmitToast = ({ workflowName, startedAt, params }) => {
</SectionRow>
)}

<SectionRow label={`Input: ${inputCount} ${dataType}${inputCount !== 1 ? "s" : ""}`} sectionKey="input">
<SectionRow label={`Input: ${inputCount} ${dataType}${inputCount !== 1 ? "s" : ""}${attachmentCount > 0 ? ` + ${attachmentCount} file${attachmentCount !== 1 ? "s" : ""}` : ""}`} sectionKey="input">
{params.IDs?.slice(0, MAX_INPUT_IDS_SHOWN).map((id) => <div key={id}>• {dataType} #{id}</div>)}
{inputCount > MAX_INPUT_IDS_SHOWN && <div className="opacity-60">…and {inputCount - MAX_INPUT_IDS_SHOWN} more</div>}
{params.useZarrFormat && <div>• Format: ZARR</div>}
{batchInfo && <div>• Batch: {batchInfo}</div>}
{fileAttachmentEntries.flatMap(([k, v]) => {
const ids = Array.isArray(v) ? v.filter((x) => x != null && x !== "") : (v != null && v !== "" ? [v] : []);
if (!ids.length) return [];
const label = inputById[k]?.name || k;
return ids.map((id) => <div key={`${k}-${id}`}>• {label}: Attachment #{id}</div>);
})}
</SectionRow>

{wfParams.length > 0 && (
<SectionRow label={`Parameters (${wfParams.length})`} sectionKey="params">
{(wfParams.length > 0 || params.version) && (
<SectionRow label={`Parameters (${wfParams.length + (params.version ? 1 : 0)})`} sectionKey="params">
{params.version && <div>• version: <strong>{params.version}</strong></div>}
{wfParams.map(([k, v]) => (
<div key={k}>• {k}: <strong>{String(v)}</strong></div>
Expand Down Expand Up @@ -215,7 +250,6 @@ export const AppProvider = ({ children }) => {
}, []);

const loadThumbnails = async (imageIds) => {
setLoading(true);
setError(null);

try {
Expand All @@ -236,8 +270,6 @@ export const AppProvider = ({ children }) => {
}));
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};

Expand Down Expand Up @@ -279,6 +311,24 @@ export const AppProvider = ({ children }) => {
if (images.length > 0) {
allImages = [...allImages, ...imagesWithSource];

// Flush each page into state immediately so thumbnails can start loading
// for the first page while remaining pages are still in flight.
setState(prev => ({
...prev,
omeroFileTreeData: {
...prev.omeroFileTreeData,
[index]: {
...dataset,
children: allImages,
},
},
images: [
...new Map(
[...(prev.images || []), ...allImages].map((img) => [img.id, img])
).values(),
],
}));

// Check if we have fetched enough images
if (allImages.length >= childCount) {
keepFetching = false; // We fetched enough images
Expand All @@ -289,24 +339,6 @@ export const AppProvider = ({ children }) => {
keepFetching = false; // No more images to fetch
}
}

// Store images in the parent structure in state.omeroFileTreeData
// Use functional setState to avoid race condition when loading multiple datasets concurrently
setState(prev => ({
...prev,
omeroFileTreeData: {
...prev.omeroFileTreeData,
[index]: {
...dataset,
children: allImages,
},
},
images: [
...new Map(
[...(prev.images || []), ...allImages].map((img) => [img.id, img])
).values(),
],
}));
} else if (type === "plate") {
const plateId = parseInt(id, 10);
// Use our existing API service functions
Expand Down Expand Up @@ -368,7 +400,7 @@ export const AppProvider = ({ children }) => {
toaster.show({
intent: "success",
icon: "tick-circle",
message: <WorkflowSubmitToast workflowName={workflowName} startedAt={startedAt} params={params} />,
message: <WorkflowSubmitToast workflowName={workflowName} startedAt={startedAt} params={params} metadata={state.selectedWorkflow?.metadata} />,
timeout: 0,
});
} catch (err) {
Expand Down
107 changes: 86 additions & 21 deletions webapp/src/apiService.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,75 @@ export const fetchSlurmStatus = async () => {
return apiRequest(urls.api_slurm_status, "GET");
};

// Fetch metadata for a specific workflow
export const fetchWorkflowMetadata = async (workflow) => {
/**
* Fetch OMERO file annotations (attachments) accessible to the current user.
* @param {string[]} formats - Optional list of file extensions to filter by (e.g. ["csv", "parquet"])
* @param {string} search - Optional substring to filter by filename
* @param {number} groupId - Optional OMERO group ID to scope the query
* @returns {Promise<{attachments: Array}>}
*/

// Module-level cache so multiple OmeroAttachmentBrowser instances that render
// at the same time (one per file-type param) share a single HTTP request.
// Keyed by group ID, expires after 60 s.
const _attachmentsCache = {}; // { [groupKey]: { ts, promise } }
const ATTACHMENTS_TTL_MS = 60_000;

export const fetchAttachments = (groupId = null) => {
const { urls, user } = getDjangoConstants();
const resolvedGroup = groupId !== null ? groupId : user.active_group_id;
const key = String(resolvedGroup);
const now = Date.now();
const cached = _attachmentsCache[key];
if (cached && now - cached.ts < ATTACHMENTS_TTL_MS) {
return cached.promise;
}
const promise = apiRequest(
`${urls.api_attachments}?group=${resolvedGroup}&_=${now}`,
"GET"
);
_attachmentsCache[key] = { ts: now, promise };
return promise;
};

/** Invalidate the attachment cache (call when user manually refreshes). */
export const invalidateAttachmentsCache = (groupId = null) => {
if (groupId !== null) {
delete _attachmentsCache[String(groupId)];
} else {
Object.keys(_attachmentsCache).forEach((k) => delete _attachmentsCache[k]);
}
};

/**
* Fetch file annotations attached to a single OMERO object.
* Used by the By-Parent tree browser to lazily load attachments per node.
*
* @param {string} objectType - "Project" | "Dataset" | "Image" | "Plate" | "Screen"
* @param {number} objectId
* @returns {Promise<{annotations: Array}>}
*/
export const fetchObjectAnnotations = async (objectType, objectId) => {
const { urls } = getDjangoConstants();
const workflowMetadataUrl = `${urls.workflows}${workflow}/`; // analyzer detail includes metadata
return apiRequest(workflowMetadataUrl, "GET");
return apiRequest(
`${urls.api_object_annotations}?object_type=${encodeURIComponent(objectType)}&object_id=${objectId}`,
"GET"
);
};

// Fetch metadata for a specific workflow, or descriptor info for an unsaved repo URL.
// - fetchWorkflowMetadata(workflowName) → GET /api/analyzer/workflows/<name>/
// - fetchWorkflowMetadata(null, repoUrl) → GET /api/analyzer/workflows/_/?repo=<url> (urls.workflow_metadata)
// Returns the full biomero-schema descriptor dict including 'requires-zarr',
// 'requires-plate', and 'name' (tool name from descriptor).
export const fetchWorkflowMetadata = async (workflow, repoUrl = null) => {
const { urls } = getDjangoConstants();
if (repoUrl) {
return apiRequest(
`${urls.workflow_metadata}?repo=${encodeURIComponent(repoUrl)}`, "GET"
);
}
return apiRequest(`${urls.workflows}${workflow}/`, "GET");
};

// GitHub URL is included in fetchWorkflowMetadata().githubUrl
Expand Down Expand Up @@ -412,26 +476,27 @@ export const extractGitHubInfo = (repoUrl) => {
};
};

/**
* Fetches the name field from a descriptor.json in a GitHub repository.
* Uses raw.githubusercontent.com (no API rate limits).
* @param {string} repoUrl - GitHub repository URL (e.g., https://github.com/owner/repo/tree/v1.0.0)
* @returns {Promise<string|null>} The name field from descriptor.json, lowercased and slugified, or null
*/
export const fetchDescriptorName = async (repoUrl) => {
const info = extractGitHubInfo(repoUrl);
if (!info?.owner || !info?.repo) return null;
export const slugify = (name) => name.toLowerCase().replace(/[\s-]+/g, '_').replace(/[^a-z0-9_]/g, '');

// In-memory cache for workflow metadata (session-scoped, keyed by repo URL).
const _metadataCache = new Map();

const ref = info.currentVersion || 'main';
const rawUrl = `https://raw.githubusercontent.com/${info.owner}/${info.repo}/${ref}/descriptor.json`;
export const fetchWorkflowMetadataCached = async (repoUrl) => {
if (_metadataCache.has(repoUrl)) return _metadataCache.get(repoUrl);
const result = await fetchWorkflowMetadata(null, repoUrl);
_metadataCache.set(repoUrl, result);
return result;
};

/**
* Fetches the container image reference via the backend (cached).
* Returns the full image string e.g. "cellularimagingcf/w_cellpose:v1.0.0", or null.
*/
export const fetchContainerImage = async (repoUrl) => {
if (!repoUrl) return null;
try {
const response = await fetch(rawUrl);
if (!response.ok) return null;
const descriptor = await response.json();
if (!descriptor?.name) return null;
// Slugify: lowercase, spaces/hyphens → underscores, strip non-alphanumeric except _
return descriptor.name.toLowerCase().replace(/[\s-]+/g, '_').replace(/[^a-z0-9_]/g, '');
const metadata = await fetchWorkflowMetadataCached(repoUrl);
return metadata?.['container-image']?.image ?? null;
} catch {
return null;
}
Expand Down
10 changes: 9 additions & 1 deletion webapp/src/biomero/components/CollapsibleSection.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useState } from "react";
import { Collapse, Button, H5, Icon, Tag, Tooltip } from "@blueprintjs/core";

const CollapsibleSection = ({ title, children, versionSummary, versionCheckLoading, onRefresh }) => {
const CollapsibleSection = ({ title, children, versionSummary, versionCheckLoading, onRefresh, errorCount }) => {
const [isOpen, setIsOpen] = useState(false);

return (
Expand All @@ -13,6 +13,14 @@ const CollapsibleSection = ({ title, children, versionSummary, versionCheckLoadi
className="mr-2"
/>
<span>{title}</span>
{/* Validation error count tag — same style as version warning tags */}
{errorCount > 0 && (
<Tooltip content="Expand this section to see and fix the validation errors">
<Tag minimal intent="danger" className="ml-2">
{errorCount} validation error{errorCount !== 1 ? 's' : ''}
</Tag>
</Tooltip>
)}
{/* Version summary for Models Settings */}
{versionSummary && (
<span className="ml-2 flex items-center">
Expand Down
Loading
Loading