+ Runs by Day
+
+
+ Date
+ Date
+
+
+ Runs
+
+
+ Files
+
+
+ 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..5199207f2
--- /dev/null
+++ b/resources/queries/targetedms/InstrumentUtilizationByMonth.query.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
diff --git a/resources/queries/targetedms/InstrumentUtilizationByMonth.sql b/resources/queries/targetedms/InstrumentUtilizationByMonth.sql
new file mode 100644
index 000000000..16f18fa62
--- /dev/null
+++ b/resources/queries/targetedms/InstrumentUtilizationByMonth.sql
@@ -0,0 +1,22 @@
+/*
+ * 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,
+ 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..1b6dc5f85 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,29 @@ 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());
+ 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());
+ 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)
{
@@ -4876,6 +4896,21 @@ public ModelAndView getView(InstrumentForm form, BindException errors)
result.addView(nameView);
}
+ var calendarView = new JspView<>("/org/labkey/targetedms/view/instrumentUtilizationCalendar.jsp", form.getName());
+ calendarView.setTitle("Utilization Calendar");
+ calendarView.setFrame(WebPartView.FrameType.PORTAL);
+ result.addView(calendarView);
+
+ 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);
+
+ var utilizationView = new JspView<>("/org/labkey/targetedms/view/instrumentUtilizationGrids.jsp", new InstrumentUtilizationBean(byDayView, byMonthView));
+ utilizationView.setTitle("Runs Acquired");
+ utilizationView.setFrame(WebPartView.FrameType.PORTAL);
+ result.addView(utilizationView);
+
QueryView folderSummaryView = createQueryView(form, errors, false, FOLDER_SUMMARY);
folderSummaryView.setTitle("Summary by Folder");
folderSummaryView.setFrame(WebPartView.FrameType.PORTAL);
@@ -4891,6 +4926,28 @@ public ModelAndView getView(InstrumentForm form, BindException errors)
}
}
+ public static class InstrumentUtilizationBean
+ {
+ private final QueryView _byDayView;
+ private final QueryView _byMonthView;
+
+ public InstrumentUtilizationBean(QueryView byDayView, QueryView byMonthView)
+ {
+ _byDayView = byDayView;
+ _byMonthView = byMonthView;
+ }
+
+ public QueryView getByDayView()
+ {
+ return _byDayView;
+ }
+
+ public QueryView getByMonthView()
+ {
+ return _byMonthView;
+ }
+ }
+
@RequiresPermission(ReadPermission.class)
public class ShowReplicatesAction extends ShowRunSingleDetailsAction
{
diff --git a/src/org/labkey/targetedms/view/instrumentUtilizationCalendar.jsp b/src/org/labkey/targetedms/view/instrumentUtilizationCalendar.jsp
new file mode 100644
index 000000000..0a2f5adae
--- /dev/null
+++ b/src/org/labkey/targetedms/view/instrumentUtilizationCalendar.jsp
@@ -0,0 +1,246 @@
+<%
+ /*
+ * 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.template.ClientDependencies" %>
+<%@ page extends="org.labkey.api.jsp.JspBase" %>
+<%!
+ @Override
+ public void addClientDependencies(ClientDependencies dependencies)
+ {
+ dependencies.add("internal/jQuery");
+ dependencies.add("targetedms/yearCalendar");
+ }
+%>
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading...
+
+
+
+
diff --git a/src/org/labkey/targetedms/view/instrumentUtilizationGrids.jsp b/src/org/labkey/targetedms/view/instrumentUtilizationGrids.jsp
new file mode 100644
index 000000000..d047598e7
--- /dev/null
+++ b/src/org/labkey/targetedms/view/instrumentUtilizationGrids.jsp
@@ -0,0 +1,55 @@
+<%
+ /*
+ * 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.InstrumentUtilizationBean" %>
+<%@ page extends="org.labkey.api.jsp.JspBase" %>
+<%
+ JspView me = HttpView.currentView();
+ InstrumentUtilizationBean bean = me.getModelBean();
+%>
+
+
+
+
+
+
+ <% me.include(bean.getByDayView(), out); %>
+
+
+ <% me.include(bean.getByMonthView(), out); %>
+
+
+
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..01864ead5
--- /dev/null
+++ b/test/src/org/labkey/test/components/targetedms/InstrumentUtilizationWebPart.java
@@ -0,0 +1,104 @@
+/*
+ * 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 "Runs Acquired" web part on the Show Instrument page. It shows an instrument's utilization,
+ * aggregated across all readable folders, as a grid of runs/files by day, with a client-side toggle
+ * to switch to a by-month summary.
+ */
+public class InstrumentUtilizationWebPart extends BodyWebPart
+{
+ public static final String DEFAULT_TITLE = "Runs Acquired";
+ public static final String BY_DAY_REGION = "UtilizationByDay";
+ public static final String BY_MONTH_REGION = "UtilizationByMonth";
+ public static final String FILES_COLUMN = "Files";
+
+ public InstrumentUtilizationWebPart(WebDriver driver)
+ {
+ super(driver, DEFAULT_TITLE);
+ WebDriverWrapper.waitFor(() -> elementCache().byDayToggle.isDisplayed(),
+ "Runs Acquired web part did not load", getWrapper().defaultWaitForPage);
+ }
+
+ public boolean isByDayVisible()
+ {
+ return elementCache().byDayGrid.isDisplayed();
+ }
+
+ public boolean isByMonthVisible()
+ {
+ return elementCache().byMonthGrid.isDisplayed();
+ }
+
+ public InstrumentUtilizationWebPart showByDay()
+ {
+ if (!isByDayVisible())
+ elementCache().byDayToggle.click();
+ WebDriverWrapper.waitFor(this::isByDayVisible, "By Day grid did not become visible", 5000);
+ return this;
+ }
+
+ public InstrumentUtilizationWebPart showByMonth()
+ {
+ if (!isByMonthVisible())
+ elementCache().byMonthToggle.click();
+ WebDriverWrapper.waitFor(this::isByMonthVisible, "By Month grid did not become visible", 5000);
+ return this;
+ }
+
+ public DataRegionTable getByDayTable()
+ {
+ return new DataRegionTable(BY_DAY_REGION, getDriver());
+ }
+
+ public DataRegionTable getByMonthTable()
+ {
+ return new DataRegionTable(BY_MONTH_REGION, getDriver());
+ }
+
+ /** 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 byDayToggle = Locator.id("utilizationToggleDay").findWhenNeeded(this);
+ final WebElement byMonthToggle = Locator.id("utilizationToggleMonth").findWhenNeeded(this);
+ final WebElement byDayGrid = Locator.id("utilizationByDayGrid").findWhenNeeded(this);
+ final WebElement byMonthGrid = Locator.id("utilizationByMonthGrid").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..e2b869b77
--- /dev/null
+++ b/test/src/org/labkey/test/tests/targetedms/TargetedMSInstrumentUtilizationTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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)));
+
+ verifyCalendar();
+ verifyRunsAcquiredGrids(expectedCrossFolderFileCount);
+ }
+
+ private void verifyCalendar()
+ {
+ log("Verifying the cross-folder Utilization Calendar renders");
+ assertElementPresent(Locator.tagWithClass("span", "labkey-wp-title-text").withText("Utilization Calendar"));
+
+ // 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 verifyRunsAcquiredGrids(int expectedCrossFolderFileCount)
+ {
+ InstrumentUtilizationWebPart utilization = new InstrumentUtilizationWebPart(getDriver());
+
+ log("Verifying the By Day grid is shown by default and aggregates across folders");
+ assertTrue("By Day grid should be visible by default", utilization.isByDayVisible());
+ assertFalse("By Month grid should be hidden by default", utilization.isByMonthVisible());
+
+ DataRegionTable byDay = utilization.getByDayTable();
+ assertTrue("By Day grid is missing expected columns",
+ byDay.getColumnLabels().containsAll(List.of("Date", "Runs", "Files")));
+ assertEquals("By Day grid should sum files across both folders",
+ expectedCrossFolderFileCount, utilization.getTotalFiles(byDay));
+
+ log("Toggling to the By Month summary");
+ utilization.showByMonth();
+ assertTrue("By Month grid should be visible after toggling", utilization.isByMonthVisible());
+ assertFalse("By Day grid should be hidden after toggling", utilization.isByDayVisible());
+
+ DataRegionTable byMonth = utilization.getByMonthTable();
+ assertTrue("By Month grid is missing expected columns",
+ byMonth.getColumnLabels().containsAll(List.of("Month", "Runs", "Files")));
+ // 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("Toggling back to the By Day grid");
+ utilization.showByDay();
+ assertTrue("By Day grid should be visible after toggling back", utilization.isByDayVisible());
+ assertFalse("By Month grid should be hidden after toggling back", utilization.isByMonthVisible());
+ }
+
+ /** @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();
+ }
+}