+ Runs by Day
+
+
+ Date
+ Date
+
+
+ Skyline Document Count
+ /targetedms-showInstrument.view?name=${InstrumentNickname}&utilizationTab=samples&SampleFile.AcquiredTime~dateeq=${AcquisitionDate:date('yyyy-MM-dd')}
+
+
+ Replicate Count
+ /targetedms-showInstrument.view?name=${InstrumentNickname}&utilizationTab=samples&SampleFile.AcquiredTime~dateeq=${AcquisitionDate:date('yyyy-MM-dd')}
+
+
+ Instrument
+
+
+
+
+
+
diff --git a/resources/queries/targetedms/InstrumentUtilizationByDay.sql b/resources/queries/targetedms/InstrumentUtilizationByDay.sql
new file mode 100644
index 000000000..c45310152
--- /dev/null
+++ b/resources/queries/targetedms/InstrumentUtilizationByDay.sql
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2026 LabKey Corporation
+ *
+ * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0
+ */
+
+-- Number of sample files and runs acquired per day, by instrument, across all folders the user can read.
+-- Consumed by the instrument utilization calendar and the "Runs by Day" grid on the Show Instrument page.
+SELECT
+ CAST(AcquisitionDay AS TIMESTAMP) AS AcquisitionDate,
+ COUNT(*) AS FileCount,
+ COUNT(DISTINCT RunId) AS RunCount,
+ InstrumentNickname
+FROM
+ (SELECT
+ CAST(YEAR(AcquiredTime) AS VARCHAR) || '-' ||
+ (CASE WHEN MONTH(AcquiredTime) < 10 THEN '0' ELSE '' END) || CAST(MONTH(AcquiredTime) AS VARCHAR) || '-' ||
+ (CASE WHEN DAYOFMONTH(AcquiredTime) < 10 THEN '0' ELSE '' END) || CAST(DAYOFMONTH(AcquiredTime) AS VARCHAR) AS AcquisitionDay,
+ ReplicateId.RunId AS RunId,
+ InstrumentNickname
+ FROM targetedms.SampleFile
+ WHERE AcquiredTime IS NOT NULL) X
+GROUP BY AcquisitionDay, InstrumentNickname
diff --git a/resources/queries/targetedms/InstrumentUtilizationByMonth.query.xml b/resources/queries/targetedms/InstrumentUtilizationByMonth.query.xml
new file mode 100644
index 000000000..79172f3b3
--- /dev/null
+++ b/resources/queries/targetedms/InstrumentUtilizationByMonth.query.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
diff --git a/resources/queries/targetedms/InstrumentUtilizationByMonth.sql b/resources/queries/targetedms/InstrumentUtilizationByMonth.sql
new file mode 100644
index 000000000..7f6426e27
--- /dev/null
+++ b/resources/queries/targetedms/InstrumentUtilizationByMonth.sql
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2026 LabKey Corporation
+ *
+ * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0
+ */
+
+-- Number of sample files and runs acquired per month, by instrument, across all folders the user can read.
+-- Consumed by the "Runs by Month" grid on the Show Instrument page.
+SELECT
+ CAST(MonthStart || '-01' AS TIMESTAMP) AS MonthStart,
+ TIMESTAMPADD('SQL_TSI_MONTH', 1, CAST(MonthStart || '-01' AS TIMESTAMP)) AS MonthEnd,
+ COUNT(*) AS FileCount,
+ COUNT(DISTINCT RunId) AS RunCount,
+ InstrumentNickname
+FROM
+ (SELECT
+ CAST(YEAR(AcquiredTime) AS VARCHAR) || '-' ||
+ (CASE WHEN MONTH(AcquiredTime) < 10 THEN '0' ELSE '' END) || CAST(MONTH(AcquiredTime) AS VARCHAR) AS MonthStart,
+ ReplicateId.RunId AS RunId,
+ InstrumentNickname
+ FROM targetedms.SampleFile
+ WHERE AcquiredTime IS NOT NULL) X
+GROUP BY MonthStart, InstrumentNickname
diff --git a/src/org/labkey/targetedms/TargetedMSController.java b/src/org/labkey/targetedms/TargetedMSController.java
index e7f80dcd9..c3a726bd2 100644
--- a/src/org/labkey/targetedms/TargetedMSController.java
+++ b/src/org/labkey/targetedms/TargetedMSController.java
@@ -4815,6 +4815,8 @@ public ShowInstrumentAction()
}
private static final String FOLDER_SUMMARY = "FolderSummary";
+ private static final String UTILIZATION_BY_DAY = "UtilizationByDay";
+ private static final String UTILIZATION_BY_MONTH = "UtilizationByMonth";
private InstrumentForm _form;
@@ -4846,11 +4848,31 @@ protected QueryView createQueryView(InstrumentForm form, BindException errors, b
TargetedMSSchema schema = new TargetedMSSchema(getUser(), getContainer());
return schema.createView(getViewContext(), settings, errors);
}
+ if (UTILIZATION_BY_DAY.equalsIgnoreCase(dataRegion))
+ {
+ QuerySettings settings = new QuerySettings(getViewContext(), UTILIZATION_BY_DAY, "InstrumentUtilizationByDay");
+ settings.setBaseSort(new Sort("-AcquisitionDate"));
+ settings.setBaseFilter(new SimpleFilter(FieldKey.fromParts("InstrumentNickname"), form.getName()));
+ settings.setContainerFilterName(ContainerFilter.Type.AllFolders.name());
+ settings.setFieldKeys(List.of(FieldKey.fromParts("AcquisitionDate"), FieldKey.fromParts("RunCount"), FieldKey.fromParts("FileCount")));
+ TargetedMSSchema schema = new TargetedMSSchema(getUser(), getContainer());
+ return schema.createView(getViewContext(), settings, errors);
+ }
+ if (UTILIZATION_BY_MONTH.equalsIgnoreCase(dataRegion))
+ {
+ QuerySettings settings = new QuerySettings(getViewContext(), UTILIZATION_BY_MONTH, "InstrumentUtilizationByMonth");
+ settings.setBaseSort(new Sort("-MonthStart"));
+ settings.setBaseFilter(new SimpleFilter(FieldKey.fromParts("InstrumentNickname"), form.getName()));
+ settings.setContainerFilterName(ContainerFilter.Type.AllFolders.name());
+ settings.setFieldKeys(List.of(FieldKey.fromParts("MonthStart"), FieldKey.fromParts("RunCount"), FieldKey.fromParts("FileCount")));
+ TargetedMSSchema schema = new TargetedMSSchema(getUser(), getContainer());
+ return schema.createView(getViewContext(), settings, errors);
+ }
throw new NotFoundException("Unknown dataRegion: " + dataRegion);
}
@Override
- public ModelAndView getView(InstrumentForm form, BindException errors)
+ public ModelAndView getView(InstrumentForm form, BindException errors) throws Exception
{
if (form.getName() == null)
{
@@ -4868,29 +4890,101 @@ public ModelAndView getView(InstrumentForm form, BindException errors)
VBox result = new VBox();
+ VBox instrumentInfoView = new VBox();
for (InstrumentNickname name : names)
{
var nameView = new JspView<>("/org/labkey/targetedms/view/nickname.jsp", name);
nameView.setTitle("Instrument Info");
nameView.setFrame(WebPartView.FrameType.PORTAL);
- result.addView(nameView);
+ instrumentInfoView.addView(nameView);
}
QueryView folderSummaryView = createQueryView(form, errors, false, FOLDER_SUMMARY);
folderSummaryView.setTitle("Summary by Folder");
folderSummaryView.setFrame(WebPartView.FrameType.PORTAL);
- QueryView sampleFileView = createQueryView(form, errors, false, TargetedMSSchema.TABLE_SAMPLE_FILE);
- sampleFileView.setTitle("Samples from " + form.getName());
- sampleFileView.setFrame(WebPartView.FrameType.PORTAL);
-
- result.addView(folderSummaryView);
- result.addView(sampleFileView);
+ // Instrument info is narrow; pair it with the folder summary grid in a two-column row so the
+ // otherwise-empty space to the right of the info panel is put to use.
+ var infoRowView = new JspView<>("/org/labkey/targetedms/view/instrumentInfoRow.jsp",
+ new InstrumentInfoRowBean(instrumentInfoView, folderSummaryView));
+ infoRowView.setFrame(WebPartView.FrameType.NONE);
+ result.addView(infoRowView);
+
+ QueryView byDayView = createInitializedQueryView(form, errors, false, UTILIZATION_BY_DAY);
+ byDayView.setFrame(WebPartView.FrameType.NONE);
+ QueryView byMonthView = createInitializedQueryView(form, errors, false, UTILIZATION_BY_MONTH);
+ byMonthView.setFrame(WebPartView.FrameType.NONE);
+ QueryView sampleFileView = createInitializedQueryView(form, errors, false, TargetedMSSchema.TABLE_SAMPLE_FILE);
+ sampleFileView.setFrame(WebPartView.FrameType.NONE);
+
+ var utilizationView = new JspView<>("/org/labkey/targetedms/view/instrumentUtilization.jsp",
+ new InstrumentUtilizationBean(byDayView, byMonthView, sampleFileView, "Samples from " + form.getName()));
+ utilizationView.setTitle("Instrument Utilization Across Folders");
+ utilizationView.setFrame(WebPartView.FrameType.PORTAL);
+ result.addView(utilizationView);
return result;
}
}
+ public static class InstrumentUtilizationBean
+ {
+ private final QueryView _byDayView;
+ private final QueryView _byMonthView;
+ private final QueryView _sampleFileView;
+ private final String _sampleFileTitle;
+
+ public InstrumentUtilizationBean(QueryView byDayView, QueryView byMonthView, QueryView sampleFileView, String sampleFileTitle)
+ {
+ _byDayView = byDayView;
+ _byMonthView = byMonthView;
+ _sampleFileView = sampleFileView;
+ _sampleFileTitle = sampleFileTitle;
+ }
+
+ public QueryView getByDayView()
+ {
+ return _byDayView;
+ }
+
+ public QueryView getByMonthView()
+ {
+ return _byMonthView;
+ }
+
+ public QueryView getSampleFileView()
+ {
+ return _sampleFileView;
+ }
+
+ public String getSampleFileTitle()
+ {
+ return _sampleFileTitle;
+ }
+ }
+
+ public static class InstrumentInfoRowBean
+ {
+ private final HttpView _infoView;
+ private final HttpView _summaryView;
+
+ public InstrumentInfoRowBean(HttpView infoView, HttpView summaryView)
+ {
+ _infoView = infoView;
+ _summaryView = summaryView;
+ }
+
+ public HttpView getInfoView()
+ {
+ return _infoView;
+ }
+
+ public HttpView getSummaryView()
+ {
+ return _summaryView;
+ }
+ }
+
@RequiresPermission(ReadPermission.class)
public class ShowReplicatesAction extends ShowRunSingleDetailsAction
{
diff --git a/src/org/labkey/targetedms/query/SampleFileTable.java b/src/org/labkey/targetedms/query/SampleFileTable.java
index 56cd1e8d1..62ef9a9b8 100644
--- a/src/org/labkey/targetedms/query/SampleFileTable.java
+++ b/src/org/labkey/targetedms/query/SampleFileTable.java
@@ -184,7 +184,10 @@ public SampleFileTable(TargetedMSSchema schema, ContainerFilter cf, @Nullable Ta
// Do a query to look across the entire server for samples where the name matches with names from the
// targetedms.SampleFiles table, as currently filtered by the SampleFileTable (container and/or run)
SQLFragment sql = new SQLFragment("SELECT DISTINCT m.Container, m.CpasType FROM (\n");
- sql.append("SELECT COALESCE(ra.value, sf.SampleId, sf.SampleName) AS SampleIdentifier FROM \n");
+ // DISTINCT collapses the many sample files that share an identifier down to the distinct set, so the
+ // planner builds the hash on this small side and scans exp.material once as the probe (instead of
+ // hashing and spilling the entire, server-wide material table).
+ sql.append("SELECT DISTINCT COALESCE(ra.value, sf.SampleId, sf.SampleName) AS SampleIdentifier FROM \n");
sql.append(TargetedMSManager.getTableInfoSampleFile(), "sf");
sql.append(" INNER JOIN \n");
sql.append(TargetedMSManager.getTableInfoReplicate(), "rep");
diff --git a/src/org/labkey/targetedms/view/instrumentInfoRow.jsp b/src/org/labkey/targetedms/view/instrumentInfoRow.jsp
new file mode 100644
index 000000000..0a9eebfbc
--- /dev/null
+++ b/src/org/labkey/targetedms/view/instrumentInfoRow.jsp
@@ -0,0 +1,74 @@
+<%
+ /*
+ * Copyright (c) 2026 LabKey Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+%>
+<%@ page import="org.labkey.api.view.HttpView" %>
+<%@ page import="org.labkey.api.view.JspView" %>
+<%@ page import="org.labkey.targetedms.TargetedMSController.InstrumentInfoRowBean" %>
+<%@ page extends="org.labkey.api.jsp.JspBase" %>
+<%
+ JspView me = HttpView.currentView();
+ InstrumentInfoRowBean bean = me.getModelBean();
+%>
+
+
+ <% me.include(bean.getInfoView(), out); %>
+
+
+ <% me.include(bean.getSummaryView(), out); %>
+
+
+
+
diff --git a/src/org/labkey/targetedms/view/instrumentUtilization.jsp b/src/org/labkey/targetedms/view/instrumentUtilization.jsp
new file mode 100644
index 000000000..346f528bf
--- /dev/null
+++ b/src/org/labkey/targetedms/view/instrumentUtilization.jsp
@@ -0,0 +1,357 @@
+<%
+ /*
+ * Copyright (c) 2026 LabKey Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+%>
+<%@ page import="org.labkey.api.view.HttpView" %>
+<%@ page import="org.labkey.api.view.JspView" %>
+<%@ page import="org.labkey.api.view.template.ClientDependencies" %>
+<%@ page import="org.labkey.targetedms.TargetedMSController.InstrumentUtilizationBean" %>
+<%@ page extends="org.labkey.api.jsp.JspBase" %>
+<%!
+ @Override
+ public void addClientDependencies(ClientDependencies dependencies)
+ {
+ dependencies.add("internal/jQuery");
+ dependencies.add("targetedms/yearCalendar");
+ }
+%>
+<%
+ JspView me = HttpView.currentView();
+ InstrumentUtilizationBean bean = me.getModelBean();
+%>
+
+
+
+
+
+
+
+
diff --git a/test/src/org/labkey/test/components/targetedms/InstrumentUtilizationWebPart.java b/test/src/org/labkey/test/components/targetedms/InstrumentUtilizationWebPart.java
new file mode 100644
index 000000000..1c78f540f
--- /dev/null
+++ b/test/src/org/labkey/test/components/targetedms/InstrumentUtilizationWebPart.java
@@ -0,0 +1,161 @@
+/*
+ * Copyright (c) 2026 LabKey Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.labkey.test.components.targetedms;
+
+import org.labkey.test.Locator;
+import org.labkey.test.WebDriverWrapper;
+import org.labkey.test.components.BodyWebPart;
+import org.labkey.test.util.DataRegionTable;
+import org.openqa.selenium.WebDriver;
+import org.openqa.selenium.WebElement;
+
+/**
+ * The "Instrument Utilization Across Folders" web part on the Show Instrument page. It shows an
+ * instrument's utilization, aggregated across all readable folders, in four tabs: a heatmap calendar,
+ * a by-month summary grid, a by-day summary grid, and the full sample-file listing. Only the active
+ * tab's pane is visible at a time.
+ */
+public class InstrumentUtilizationWebPart extends BodyWebPart
+{
+ public static final String DEFAULT_TITLE = "Instrument Utilization Across Folders";
+ public static final String BY_DAY_REGION = "UtilizationByDay";
+ public static final String BY_MONTH_REGION = "UtilizationByMonth";
+ public static final String SAMPLE_FILE_REGION = "SampleFile";
+ public static final String FILES_COLUMN = "FileCount";
+
+ public InstrumentUtilizationWebPart(WebDriver driver)
+ {
+ super(driver, DEFAULT_TITLE);
+ WebDriverWrapper.waitFor(() -> elementCache().calendarTab.isDisplayed(),
+ "Instrument utilization web part did not load", getWrapper().defaultWaitForPage);
+ }
+
+ public boolean isCalendarVisible()
+ {
+ return elementCache().calendarPane.isDisplayed();
+ }
+
+ public boolean isByDayVisible()
+ {
+ return elementCache().byDayPane.isDisplayed();
+ }
+
+ public boolean isByMonthVisible()
+ {
+ return elementCache().byMonthPane.isDisplayed();
+ }
+
+ public boolean isSamplesVisible()
+ {
+ return elementCache().samplesPane.isDisplayed();
+ }
+
+ public InstrumentUtilizationWebPart showCalendar()
+ {
+ if (!isCalendarVisible())
+ elementCache().calendarTab.click();
+ WebDriverWrapper.waitFor(this::isCalendarVisible, "Calendar tab did not become visible", 5000);
+ return this;
+ }
+
+ public InstrumentUtilizationWebPart showByDay()
+ {
+ if (!isByDayVisible())
+ elementCache().byDayTab.click();
+ WebDriverWrapper.waitFor(this::isByDayVisible, "By Day tab did not become visible", 5000);
+ return this;
+ }
+
+ public InstrumentUtilizationWebPart showByMonth()
+ {
+ if (!isByMonthVisible())
+ elementCache().byMonthTab.click();
+ WebDriverWrapper.waitFor(this::isByMonthVisible, "By Month tab did not become visible", 5000);
+ return this;
+ }
+
+ public InstrumentUtilizationWebPart showSamples()
+ {
+ if (!isSamplesVisible())
+ elementCache().samplesTab.click();
+ WebDriverWrapper.waitFor(this::isSamplesVisible, "Samples tab did not become visible", 5000);
+ return this;
+ }
+
+ /** Selects the By Day tab (a hidden data region reports no cell text) and returns its grid. */
+ public DataRegionTable getByDayTable()
+ {
+ showByDay();
+ return new DataRegionTable(BY_DAY_REGION, getDriver());
+ }
+
+ /** Selects the By Month tab (a hidden data region reports no cell text) and returns its grid. */
+ public DataRegionTable getByMonthTable()
+ {
+ showByMonth();
+ return new DataRegionTable(BY_MONTH_REGION, getDriver());
+ }
+
+ /** Selects the Samples tab (a hidden data region reports no cell text) and returns the sample-file grid. */
+ public DataRegionTable getSamplesTable()
+ {
+ showSamples();
+ return new DataRegionTable(SAMPLE_FILE_REGION, getDriver());
+ }
+
+ /**
+ * Clicks the "Replicate Count" drill-in link in the given summary-grid row. The link reloads the page
+ * on the Samples tab with the row's date-range filter applied, so this waits for the reload and returns
+ * a fresh web part handle positioned on the (filtered) Samples tab.
+ */
+ public InstrumentUtilizationWebPart drillIntoSamples(DataRegionTable summaryTable, int row)
+ {
+ getWrapper().clickAndWait(summaryTable.link(row, "Replicate Count"));
+ InstrumentUtilizationWebPart reloaded = new InstrumentUtilizationWebPart(getDriver());
+ WebDriverWrapper.waitFor(reloaded::isSamplesVisible,
+ "Samples tab did not open after the drill-in navigation", reloaded.getWrapper().defaultWaitForPage);
+ return reloaded;
+ }
+
+ /** Sum of the (integer) "Files" column, i.e. the total number of sample files represented by the grid. */
+ public int getTotalFiles(DataRegionTable table)
+ {
+ int total = 0;
+ for (String value : table.getColumnDataAsText(FILES_COLUMN))
+ {
+ total += Integer.parseInt(value.trim());
+ }
+ return total;
+ }
+
+ @Override
+ protected Elements newElementCache()
+ {
+ return new Elements();
+ }
+
+ public class Elements extends BodyWebPart>.ElementCache
+ {
+ final WebElement calendarTab = Locator.css("#utilizationTabs a[data-utilization-tab='calendar']").findWhenNeeded(this);
+ final WebElement byMonthTab = Locator.css("#utilizationTabs a[data-utilization-tab='month']").findWhenNeeded(this);
+ final WebElement byDayTab = Locator.css("#utilizationTabs a[data-utilization-tab='day']").findWhenNeeded(this);
+ final WebElement samplesTab = Locator.css("#utilizationTabs a[data-utilization-tab='samples']").findWhenNeeded(this);
+ final WebElement calendarPane = Locator.id("utilizationTabCalendar").findWhenNeeded(this);
+ final WebElement byMonthPane = Locator.id("utilizationTabMonth").findWhenNeeded(this);
+ final WebElement byDayPane = Locator.id("utilizationTabDay").findWhenNeeded(this);
+ final WebElement samplesPane = Locator.id("utilizationTabSamples").findWhenNeeded(this);
+ }
+}
diff --git a/test/src/org/labkey/test/tests/targetedms/TargetedMSInstrumentUtilizationTest.java b/test/src/org/labkey/test/tests/targetedms/TargetedMSInstrumentUtilizationTest.java
new file mode 100644
index 000000000..b3791c870
--- /dev/null
+++ b/test/src/org/labkey/test/tests/targetedms/TargetedMSInstrumentUtilizationTest.java
@@ -0,0 +1,201 @@
+/*
+ * Copyright (c) 2026 LabKey Corporation
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.labkey.test.tests.targetedms;
+
+import org.jetbrains.annotations.Nullable;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.labkey.remoteapi.CommandException;
+import org.labkey.remoteapi.query.Filter;
+import org.labkey.remoteapi.query.SelectRowsCommand;
+import org.labkey.remoteapi.query.SelectRowsResponse;
+import org.labkey.test.BaseWebDriverTest;
+import org.labkey.test.Locator;
+import org.labkey.test.WebTestHelper;
+import org.labkey.test.components.targetedms.InstrumentUtilizationWebPart;
+import org.labkey.test.util.DataRegionTable;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Verifies the instrument-scoped, cross-folder utilization views on the Show Instrument page: the
+ * "Utilization Calendar" web part and the "Runs Acquired" by-day/by-month grids. The same Skyline
+ * document is imported into two folders so we can assert that the views aggregate across every folder
+ * the user can read, not just the current one.
+ */
+@Category({})
+@BaseWebDriverTest.ClassTimeout(minutes = 5)
+public class TargetedMSInstrumentUtilizationTest extends TargetedMSTest
+{
+ private static final String QC_SUBFOLDER = "QC Subfolder";
+
+ private String _instrumentName;
+
+ @Override
+ protected @Nullable String getProjectName()
+ {
+ return getClass().getSimpleName() + " Project";
+ }
+
+ @BeforeClass
+ public static void initProject()
+ {
+ TargetedMSInstrumentUtilizationTest init = getCurrentTest();
+ init.doInit();
+ }
+
+ private void doInit()
+ {
+ setupFolder(FolderType.QC);
+ setupSubfolder(getProjectName(), QC_SUBFOLDER, FolderType.QC);
+
+ // Import the same document into two folders so the same instrument has data in both
+ goToProjectHome();
+ importData(SProCoP_FILE);
+
+ clickFolder(QC_SUBFOLDER);
+ importData(SProCoP_FILE);
+ }
+
+ @Test
+ public void testCrossFolderInstrumentUtilization() throws IOException, CommandException
+ {
+ _instrumentName = getInstrumentNickname();
+
+ // The two folders hold identical imports, so the cross-folder total should be exactly double
+ // the count in a single folder.
+ int singleFolderFileCount = getFileCount(getProjectName());
+ int expectedCrossFolderFileCount = singleFolderFileCount * 2;
+ assertTrue("Expected the single folder to contain sample files with acquisition times", singleFolderFileCount > 0);
+
+ beginAt(WebTestHelper.buildURL("targetedms", getProjectName(), "showInstrument", Map.of("name", _instrumentName)));
+
+ InstrumentUtilizationWebPart utilization = new InstrumentUtilizationWebPart(getDriver());
+ verifyCalendarTab(utilization);
+ verifyRunsGrids(utilization, expectedCrossFolderFileCount);
+ verifyDrillIntoSamples(utilization);
+ }
+
+ /**
+ * The Skyline Document Count / Replicate Count cells in the summary grids drill into the Samples tab
+ * with a matching date filter applied. Verify the by-day and by-month links land on the Samples tab
+ * and narrow the sample-file grid to exactly the replicates that row represents.
+ */
+ private void verifyDrillIntoSamples(InstrumentUtilizationWebPart utilization)
+ {
+ log("Drilling into the Samples tab from a Summary by Day count link");
+ DataRegionTable byDay = utilization.getByDayTable();
+ int dayReplicates = Integer.parseInt(byDay.getDataAsText(0, "Replicate Count").trim());
+ utilization = utilization.drillIntoSamples(byDay, 0);
+ assertTrue("Samples tab should open when a day's count is clicked", utilization.isSamplesVisible());
+ waitForSamplesRowCount(dayReplicates);
+
+ log("Drilling into the Samples tab from a Summary by Month count link");
+ DataRegionTable byMonth = utilization.getByMonthTable();
+ int monthReplicates = Integer.parseInt(byMonth.getDataAsText(0, "Replicate Count").trim());
+ utilization = utilization.drillIntoSamples(byMonth, 0);
+ assertTrue("Samples tab should open when a month's count is clicked", utilization.isSamplesVisible());
+ waitForSamplesRowCount(monthReplicates);
+ }
+
+ /** Waits for the sample-file grid to refresh to the expected filtered row count (rebuilt each poll to dodge staleness). */
+ private void waitForSamplesRowCount(int expected)
+ {
+ waitFor(() -> new DataRegionTable(InstrumentUtilizationWebPart.SAMPLE_FILE_REGION, getDriver()).getDataRowCount() == expected,
+ "Samples grid did not filter to the expected " + expected + " row(s)", 10_000);
+ }
+
+ private void verifyCalendarTab(InstrumentUtilizationWebPart utilization)
+ {
+ log("Verifying the utilization web part opens on the Utilization Calendar tab");
+ assertTrue("The Utilization Calendar tab should be active by default", utilization.isCalendarVisible());
+
+ // Wait for the async selectRows call to populate the calendar with day cells
+ waitForElement(Locator.tagWithClassContaining("div", "day-content"));
+ assertEquals("Calendar should default to a single month",
+ "1 month", getSelectedOptionText(Locator.id("utilizationMonthNumberSelect")));
+ }
+
+ private void verifyRunsGrids(InstrumentUtilizationWebPart utilization, int expectedCrossFolderFileCount)
+ {
+ log("Selecting the Runs by Day tab and verifying it aggregates across folders");
+ utilization.showByDay();
+ assertTrue("By Day grid should be visible after selecting its tab", utilization.isByDayVisible());
+ assertFalse("Calendar should be hidden when the By Day tab is active", utilization.isCalendarVisible());
+
+ DataRegionTable byDay = utilization.getByDayTable();
+ assertTrue("By Day grid is missing expected columns",
+ byDay.getColumnLabels().containsAll(List.of("Date", "Skyline Document Count", "Replicate Count")));
+ assertEquals("By Day grid should sum files across both folders",
+ expectedCrossFolderFileCount, utilization.getTotalFiles(byDay));
+
+ log("Selecting the Runs by Month tab");
+ utilization.showByMonth();
+ assertTrue("By Month grid should be visible after selecting its tab", utilization.isByMonthVisible());
+ assertFalse("By Day grid should be hidden when the By Month tab is active", utilization.isByDayVisible());
+
+ DataRegionTable byMonth = utilization.getByMonthTable();
+ assertTrue("By Month grid is missing expected columns",
+ byMonth.getColumnLabels().containsAll(List.of("Month", "Skyline Document Count", "Replicate Count")));
+ // Grouping by month rather than day changes the row count but not the overall file total
+ assertEquals("By Month grid should sum to the same cross-folder file total",
+ expectedCrossFolderFileCount, utilization.getTotalFiles(byMonth));
+
+ log("Selecting the Samples tab and verifying the full sample-file listing aggregates across folders");
+ utilization.showSamples();
+ assertTrue("Samples grid should be visible after selecting its tab", utilization.isSamplesVisible());
+ assertFalse("By Month grid should be hidden when the Samples tab is active", utilization.isByMonthVisible());
+
+ DataRegionTable samples = utilization.getSamplesTable();
+ assertEquals("Samples grid should list every sample file across both folders",
+ expectedCrossFolderFileCount, samples.getDataRowCount());
+
+ log("Selecting the Calendar tab again");
+ utilization.showCalendar();
+ assertTrue("Calendar should be visible after selecting its tab", utilization.isCalendarVisible());
+ assertFalse("Samples grid should be hidden when the calendar tab is active", utilization.isSamplesVisible());
+ }
+
+ /** @return the default nickname (model - serial number) for the instrument that acquired the imported data */
+ private String getInstrumentNickname() throws IOException, CommandException
+ {
+ SelectRowsCommand command = new SelectRowsCommand("targetedms", "SampleFile");
+ command.setColumns(List.of("InstrumentNickname"));
+ command.setMaxRows(1);
+ SelectRowsResponse response = command.execute(createDefaultConnection(), getProjectName());
+ assertFalse("No sample files were imported", response.getRows().isEmpty());
+ return (String) response.getRows().get(0).get("InstrumentNickname");
+ }
+
+ /** @return the number of sample files (with an acquisition time) for the instrument in a single container */
+ private int getFileCount(String containerPath) throws IOException, CommandException
+ {
+ SelectRowsCommand command = new SelectRowsCommand("targetedms", "SampleFile");
+ command.setColumns(List.of("Id"));
+ command.setFilters(List.of(
+ new Filter("InstrumentNickname", _instrumentName),
+ new Filter("AcquiredTime", null, Filter.Operator.NONBLANK)));
+ SelectRowsResponse response = command.execute(createDefaultConnection(), containerPath);
+ return response.getRowCount().intValue();
+ }
+}