diff --git a/resources/queries/targetedms/InstrumentUtilizationByDay.query.xml b/resources/queries/targetedms/InstrumentUtilizationByDay.query.xml new file mode 100644 index 000000000..eb4cdfaee --- /dev/null +++ b/resources/queries/targetedms/InstrumentUtilizationByDay.query.xml @@ -0,0 +1,24 @@ + + + + + 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 @@ + + + + + Runs by Month + + + Month + yyyy-MM + + + Runs + + + Files + + + Instrument + + +
+
+
+
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); %> +
+ + + 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(); + } +}