diff --git a/CageUI/resources/schemas/dbscripts/postgresql/cageui-26.000-26.001.sql b/CageUI/resources/schemas/dbscripts/postgresql/cageui-26.000-26.001.sql new file mode 100644 index 000000000..e995899e5 --- /dev/null +++ b/CageUI/resources/schemas/dbscripts/postgresql/cageui-26.000-26.001.sql @@ -0,0 +1,57 @@ +/* + * + * * Copyright (c) 2026 Board of Regents of the University of Wisconsin System + * * + * * 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. + * + */ + +INSERT INTO ehr_lookups.lookup_sets (setname, label, description, keyField, container) +select 'cageui_svg_urls' as setname, + 'SVG Urls Field Values' as label, + 'List of URLS for room items' as description, + 'value' as keyField, + container from ehr_lookups.lookup_sets where setname='ancestry'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'cage' as value, '/cageui/static/cage.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'pen' as value, '/cageui/static/pen.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'tempCage' as value, '/cageui/static/cage.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'playCage' as value, '/cageui/static/pen.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'roomDivider' as value, '/cageui/static/roomDivider.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'drain' as value, '/cageui/static/drain.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'door' as value, '/cageui/static/door.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'gateClosed' as value, '/cageui/static/gateClosed.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'gateOpen' as value, '/cageui/static/gateOpen.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'top' as value, '/cageui/static/top.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; + +insert into ehr_lookups.lookups (set_name,container,value, title) +select setname, container, 'bottom' as value, '/cageui/static/bottom.svg' as title from ehr_lookups.lookup_sets where setname='cageui_svg_urls'; diff --git a/CageUI/resources/web/CageUI/static/legend.svg b/CageUI/resources/web/CageUI/static/legend.svg index a64e32c8e..51a97407f 100644 --- a/CageUI/resources/web/CageUI/static/legend.svg +++ b/CageUI/resources/web/CageUI/static/legend.svg @@ -16,112 +16,119 @@ - * limitations under the License. - */ --> - - - + + - - - - - - + + - - - - - + + + + - - - - - + - - + - - - - - - - + + + - Solid Divider + Solid Divider - - Protected Contact Divider + Protected Contact Divider - - Visual Contact Divider + Visual Contact Divider - - Privacy Divider + Privacy Divider - - Standard Floor + Standard Floor - - Mesh Floor + Mesh Floor - - Mesh Floor x2 + Mesh Floor x2 - Extension - - + C-Tunnel - - - - + - - - + - Social Panel Divider + Social Panel Divider - - + - Restraint + Restraint - - - + - Window Blind + Window Blind - - - + + + + + + Locked Divider + \ No newline at end of file diff --git a/CageUI/src/client/cageui.scss b/CageUI/src/client/cageui.scss index 8f9f57618..a3b6bc49a 100644 --- a/CageUI/src/client/cageui.scss +++ b/CageUI/src/client/cageui.scss @@ -1148,7 +1148,7 @@ .room-list-items { overflow-y: auto; - padding: 5px; + padding: 5px 15px 5px 5px; } .arrow { @@ -1166,13 +1166,19 @@ border: 1px solid black; } +.room-dir-header-container { + display: flex; + align-items: center; + justify-content: space-between; +} + .room-dir-header { cursor: pointer; font-weight: bold; display: flex; align-items: center; - justify-content: space-between; font-size: x-large; + flex-grow: 1; } .room-dir-room-obj { @@ -1180,14 +1186,20 @@ border-bottom: 1px solid lightgrey; } +.room-dir-rack-obj-container { + display: flex; + align-items: center; + justify-content: space-between; +} + .room-dir-rack-obj { cursor: pointer; font-size: large; font-weight: bold; display: flex; align-items: center; - justify-content: space-between; margin: 15px 10px 15px 5px; + flex-grow: 1; } .room-dir-cage-obj { @@ -1199,12 +1211,12 @@ margin: 15px 10px 15px 5px; } -.room-dir-header.open .arrow { +.room-dir-header-container.open .arrow { transform: rotate(135deg); } -.room-dir-rack-obj.open .arrow { +.room-dir-rack-obj-container.open .arrow { transform: rotate(135deg); } diff --git a/CageUI/src/client/components/LoadingScreen.tsx b/CageUI/src/client/components/LoadingScreen.tsx index 821ffdc42..d7c2d91ad 100644 --- a/CageUI/src/client/components/LoadingScreen.tsx +++ b/CageUI/src/client/components/LoadingScreen.tsx @@ -22,11 +22,12 @@ import { createPortal } from 'react-dom'; interface LoadingScreenProps { isVisible: boolean; + message: string; targetElement?: HTMLElement | null; } export const LoadingScreen: FC = (props) => { - const {isVisible, targetElement} = props; + const {isVisible, message, targetElement} = props; const [container, setContainer] = useState(null); @@ -44,7 +45,7 @@ export const LoadingScreen: FC = (props) => {
-

Saving...

+

{message}

, container diff --git a/CageUI/src/client/components/home/RoomContent.tsx b/CageUI/src/client/components/home/RoomContent.tsx index 750d30bdc..e057356fe 100644 --- a/CageUI/src/client/components/home/RoomContent.tsx +++ b/CageUI/src/client/components/home/RoomContent.tsx @@ -24,9 +24,10 @@ import { CageViewContent } from './cageView/CageViewContent'; import { RackViewContent } from './rackView/RackViewContent'; import { HomeViewContent } from './HomeViewContent'; import { useHomeNavigationContext } from '../../context/HomeNavigationContextManager'; +import { LoadingScreen } from '../LoadingScreen'; export const RoomContent: FC = () => { - const {selectedPage} = useHomeNavigationContext(); + const {selectedPage, isNavLoading} = useHomeNavigationContext(); const renderContent = () => { switch (selectedPage?.selected) { @@ -43,7 +44,12 @@ export const RoomContent: FC = () => { return (
- {renderContent()} + + {!isNavLoading && renderContent()}
); }; \ No newline at end of file diff --git a/CageUI/src/client/components/home/RoomList.tsx b/CageUI/src/client/components/home/RoomList.tsx index a6a58c633..98bbed5f9 100644 --- a/CageUI/src/client/components/home/RoomList.tsx +++ b/CageUI/src/client/components/home/RoomList.tsx @@ -17,26 +17,29 @@ */ import * as React from 'react'; -import { FC, useEffect, useState } from 'react'; +import { FC, useEffect, useRef, useState } from 'react'; import '../../cageui.scss'; import { Room } from '../../types/typings'; import { ExpandedRooms, ListCage, ListRack, ListRoom } from '../../types/homeTypes'; import { labkeyActionSelectWithPromise } from '../../api/labkeyActions'; import { buildNewLocalRoom, fetchRoomData } from '../../utils/helpers'; import { useHomeNavigationContext } from '../../context/HomeNavigationContextManager'; -import { Filter } from '@labkey/api'; +import { ActionURL, Filter } from '@labkey/api'; export const RoomList: FC = () => { - const {navigateTo} = useHomeNavigationContext(); + const {navigateTo, selectedPage, setIsNavLoading} = useHomeNavigationContext(); // keeps track of which rooms have already been fetched from layout_history const [expandedRooms, setExpandedRooms] = useState({}); - const [expandedRacks, setExpandedRacks] = useState([]); + const [expandedRacks, setExpandedRacks] = useState>({}); const [allRooms, setAllRooms] = useState([]); // Stores all items fetched on load const [visibleRooms, setVisibleRooms] = useState([]); // Items currently visible const [searchQuery, setSearchQuery] = useState(''); + const roomRefs = useRef>({}); + const listContainerRef = useRef(null); + const handleSearch = (e) => { setSearchQuery(e.target.value); }; @@ -120,6 +123,18 @@ export const RoomList: FC = () => { }); }); }); + + // Sort cages within each rack and then sort racks by their first cage + tempRacks.forEach((rack) => { + rack.cages.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + }); + tempRacks.sort((a, b) => { + if (a.cages.length > 0 && b.cages.length > 0) { + return a.cages[0].name.localeCompare(b.cages[0].name, undefined, { numeric: true }); + } + return 0; + }); + return { ...prevRoom, racks: tempRacks, @@ -144,15 +159,63 @@ export const RoomList: FC = () => { })); }; + // Auto-expand and scroll based on URL parameters + useEffect(() => { + const roomName = ActionURL.getParameter("room"); + const rackId = ActionURL.getParameter("rack"); + + if (roomName) { + if (!expandedRooms[roomName]) { + toggleExpandRoom(roomName); + } + + if (rackId) { + const rackKey = `${roomName}_${rackId}`; + if (!expandedRacks[rackKey]) { + setExpandedRacks(prev => ({ + ...prev, + [rackKey]: true + })); + } + } + + // Scroll room into view + if (roomRefs.current[roomName] && listContainerRef.current) { + const container = listContainerRef.current; + const element = roomRefs.current[roomName]; + + // Use a short timeout to ensure the DOM has updated (expanded) before we calculate the offset + setTimeout(() => { + if (element && container) { + const containerRect = container.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + // elementRect.top is the distance from viewport top to element top + // containerRect.top is the distance from viewport top to container top + // relativeTop is the distance from container top to element top within the scrollable area + const relativeTop = elementRect.top - containerRect.top + container.scrollTop; + + container.scrollTo({ + top: relativeTop, + behavior: 'auto' + }); + } + }, 100); + } + } + }, [selectedPage, allRooms, visibleRooms]); + const handleRoomClick = (room: ListRoom) => { + setIsNavLoading(true); navigateTo({selected: 'Room', room: room.name}) }; const handleRackClick = (room: ListRoom, rack: ListRack) => { + setIsNavLoading(true); navigateTo({selected: 'Rack', room: room.name, rack: rack.id}); }; const handleCageClick = (room: ListRoom, rack: ListRack, cage: ListCage) => { + setIsNavLoading(true); navigateTo({selected: 'Cage', room: room.name, rack: rack.id, cage: cage.id}); }; @@ -165,25 +228,35 @@ export const RoomList: FC = () => { className={'room-search'} onChange={handleSearch} /> -
    +
      {visibleRooms.map((room, index) => ( -
      -
      handleRoomClick(room)} - className={`room-dir-header ${expandedRooms[room.name] ? 'open' : ''}`} - > - {room.name} +
      { + if (el) roomRefs.current[room.name] = el; + }} + > +
      +
      handleRoomClick(room)} + className={`room-dir-header`} + > + {room.name} +
      toggleExpandRoom(room.name)}>
      {expandedRooms[room.name] && (
        {room?.racks?.map((rack) => (
      • -
        handleRackClick(room, rack)} - className={`room-dir-rack-obj ${expandedRacks[`${room.name}_${rack.id}`] ? 'open' : ''}`} - > - {rack.name} +
        +
        handleRackClick(room, rack)} + className={`room-dir-rack-obj`} + > + {rack.name} +
        toggleExpandRack(room.name, rack.id)}>
        diff --git a/CageUI/src/client/components/home/rackView/ChangeRackPopup.tsx b/CageUI/src/client/components/home/rackView/ChangeRackPopup.tsx index 3fa788b35..821258ec0 100644 --- a/CageUI/src/client/components/home/rackView/ChangeRackPopup.tsx +++ b/CageUI/src/client/components/home/rackView/ChangeRackPopup.tsx @@ -147,7 +147,7 @@ export const ChangeRackPopup: FC = (props) => { //navigateTo({selected: 'Room', room: selectedRoom.name}); window.location.href = ActionURL.buildURL( ActionURL.getController(), - 'cageui-home', + 'home', ActionURL.getContainer(), {room: res.roomName, rack: res.rack}); @@ -167,6 +167,7 @@ export const ChangeRackPopup: FC = (props) => { {isSaving && } diff --git a/CageUI/src/client/components/home/roomView/CagePopup.tsx b/CageUI/src/client/components/home/roomView/CagePopup.tsx index 6d8ce82d3..c206d5d86 100644 --- a/CageUI/src/client/components/home/roomView/CagePopup.tsx +++ b/CageUI/src/client/components/home/roomView/CagePopup.tsx @@ -100,7 +100,6 @@ export const CagePopup: FC = (props) => { // This submission updates the room mods with the current selections. const handleSaveMods = () => { - console.log("SaveMods: ", currCageMods); validateAndApplyDefaults(currCageMods).then((res) => { const result = saveCageMods(prevCage, res); diff --git a/CageUI/src/client/components/home/roomView/RoomLayout.tsx b/CageUI/src/client/components/home/roomView/RoomLayout.tsx index a5931267d..cadb12a8e 100644 --- a/CageUI/src/client/components/home/roomView/RoomLayout.tsx +++ b/CageUI/src/client/components/home/roomView/RoomLayout.tsx @@ -122,8 +122,12 @@ export const RoomLayout: FC = (props) => { return (
        - {isSaving && }
        {showChangesMenu && diff --git a/CageUI/src/client/components/home/roomView/RoomObjectPopup.tsx b/CageUI/src/client/components/home/roomView/RoomObjectPopup.tsx index 748a58de8..40dad79c8 100644 --- a/CageUI/src/client/components/home/roomView/RoomObjectPopup.tsx +++ b/CageUI/src/client/components/home/roomView/RoomObjectPopup.tsx @@ -43,10 +43,6 @@ export const RoomObjectPopup: FC = (props) => { const [prevRoomObjId, setPrevRoomObjId] = useState((selectedObj as RoomObject).itemId); const menuRef = useRef(null); - useEffect(() => { - console.log('roomObj: ', roomObj); - }, [roomObj]); - useEffect(() => { // Check if the click was outside the menu const handleClickOutside = (event) => { diff --git a/CageUI/src/client/components/layoutEditor/Editor.tsx b/CageUI/src/client/components/layoutEditor/Editor.tsx index b83ce70f0..7f6b165f6 100644 --- a/CageUI/src/client/components/layoutEditor/Editor.tsx +++ b/CageUI/src/client/components/layoutEditor/Editor.tsx @@ -689,8 +689,9 @@ const Editor: FC = ({roomSize}) => { } }); // loads grid with new room - addPrevRoomSvgs(user, 'edit', reloadRoom, layoutSvg, undefined, undefined, setSelectedObj, contextMenuRef, setCtxMenuStyle, closeMenuThenDrag); - setReloadRoom(null); + addPrevRoomSvgs(user, 'edit', reloadRoom, layoutSvg, undefined, undefined, setSelectedObj, contextMenuRef, setCtxMenuStyle, closeMenuThenDrag).then(() => { + setReloadRoom(null); + }); }, [reloadRoom]); // Effect attaches an observer to the border_template svg. after it is injected into the dom it will run @@ -750,8 +751,8 @@ const Editor: FC = ({roomSize}) => { if (loadTemplate) { window.location.href = ActionURL.buildURL( ActionURL.getController(), - 'cageui-editLayout', - ActionURL.getContainer(), + 'editLayout', + ActionURL.getController(), {room: localRoom.name} ); } @@ -874,6 +875,7 @@ const Editor: FC = ({roomSize}) => { {startSaving && } diff --git a/CageUI/src/client/components/layoutEditor/EditorContextMenu.tsx b/CageUI/src/client/components/layoutEditor/EditorContextMenu.tsx index 31604f82d..9990e64c3 100644 --- a/CageUI/src/client/components/layoutEditor/EditorContextMenu.tsx +++ b/CageUI/src/client/components/layoutEditor/EditorContextMenu.tsx @@ -17,13 +17,13 @@ */ import * as React from 'react'; -import { FC, ReactElement, useEffect, useRef } from 'react'; +import { FC, ReactElement, useEffect, useRef, useState } from 'react'; import '../../cageui.scss'; import { Button } from 'react-bootstrap'; import { parseRoomItemType, stringToRoomItem } from '../../utils/helpers'; import { Cage, - DefaultRackTypes, + DefaultRackTypes, Rack, RackGroup, RackStringType, RackTypes, RoomItemType, @@ -31,6 +31,8 @@ import { RoomObjectTypes } from '../../types/typings'; import { SelectedObj } from '../../types/layoutEditorTypes'; +import { useLayoutEditorContext } from '../../context/LayoutEditorContextManager'; +import { findCageInGroup } from '../../utils/LayoutEditorHelpers'; interface Option { label: string; @@ -64,23 +66,19 @@ export const EditorContextMenu: FC = (props) => { type } = props; + const {localRoom, unmergeRacks} = useLayoutEditorContext(); const menuRef = useRef(null); - // Delete object for room objects - const handleDeleteObject = (e: React.MouseEvent) => { - e.stopPropagation(); - onClickDelete(); - }; + const [selectedRack, setSelectedRack] = useState(); + const [selectedRackGroup, setSelectedRackGroup] = useState(); - // Delete cage and rack for caging units - const handleDeleteCage = (e: React.MouseEvent) => { - e.stopPropagation(); - onClickDelete('cage'); - }; - const handleDeleteRack = (e: React.MouseEvent) => { - e.stopPropagation(); - onClickDelete('rack'); - }; + useEffect(() => { + if(selectedObj.selectionType === 'cage'){ + const {rack, rackGroup} = findCageInGroup((selectedObj as Cage).svgId, localRoom.rackGroups); + setSelectedRack(rack); + setSelectedRackGroup(rackGroup); + } + }, [selectedObj]); useEffect(() => { const handleClickOutside = (event) => { @@ -136,12 +134,35 @@ export const EditorContextMenu: FC = (props) => { menu.style.top = `${adjustedTop}px`; }, [ctxMenuStyle.display, ctxMenuStyle.left, ctxMenuStyle.top]); + + // Delete object for room objects + const handleDeleteObject = (e: React.MouseEvent) => { + e.stopPropagation(); + onClickDelete(); + }; + + // Delete cage and rack for caging units + const handleDeleteCage = (e: React.MouseEvent) => { + e.stopPropagation(); + onClickDelete('cage'); + }; + const handleDeleteRack = (e: React.MouseEvent) => { + e.stopPropagation(); + onClickDelete('rack'); + }; + + const handleUnmergeRack = (e: React.MouseEvent) => { + e.stopPropagation(); + unmergeRacks(selectedRackGroup, selectedRack); + closeMenu(); + } + return (
        {menuItems && menuItems.map((item, index) => { @@ -186,6 +207,15 @@ export const EditorContextMenu: FC = (props) => { > Delete Rack + + {(selectedRackGroup && selectedRackGroup.racks.length > 1) && + + }
        }
        diff --git a/CageUI/src/client/context/HomeNavigationContextManager.tsx b/CageUI/src/client/context/HomeNavigationContextManager.tsx index eace2551b..416fa3392 100644 --- a/CageUI/src/client/context/HomeNavigationContextManager.tsx +++ b/CageUI/src/client/context/HomeNavigationContextManager.tsx @@ -58,6 +58,8 @@ export const HomeNavigationContextProvider: FC = ({u const [selectedRack, setSelectedRack] = useState(null); const [selectedCage, setSelectedCage] = useState(null); + const [isNavLoading, setIsNavLoading] = useState(false); + useEffect(() => { setSelectedLocalRoom(selectedRoom); }, [selectedRoom]); @@ -122,11 +124,14 @@ export const HomeNavigationContextProvider: FC = ({u setSelectedRackGroup(null); setSelectedRack(null); setSelectedCage(null); + setIsNavLoading(false); break; case 'Room': if (page.room) { - loadRoomData(page.room); + loadRoomData(page.room).then((newRoom) => { + setIsNavLoading(false); + }); } break; @@ -138,11 +143,13 @@ export const HomeNavigationContextProvider: FC = ({u const { rack: currRack, rackGroup: currGroup } = findRackInGroup(page.rack, newRoom?.rackGroups || []); setSelectedRack(currRack); setSelectedRackGroup(currGroup); + setIsNavLoading(false); }); } else { const { rack: currRack, rackGroup: currGroup } = findRackInGroup(page.rack, selectedRoom?.rackGroups || []); setSelectedRack(currRack); setSelectedRackGroup(currGroup); + setIsNavLoading(false); } } break; @@ -160,6 +167,7 @@ export const HomeNavigationContextProvider: FC = ({u setSelectedRackGroup(currGroup); setSelectedRack(currRack); setSelectedCage(currCage); + setIsNavLoading(false); }); } else { const { @@ -170,6 +178,7 @@ export const HomeNavigationContextProvider: FC = ({u setSelectedRackGroup(currGroup); setSelectedRack(currRack); setSelectedCage(currCage); + setIsNavLoading(false); } } break; @@ -232,6 +241,8 @@ export const HomeNavigationContextProvider: FC = ({u navigateTo, setSelectedLocalRoom, userProfile, + isNavLoading, + setIsNavLoading }}> {children} diff --git a/CageUI/src/client/context/LayoutEditorContextManager.tsx b/CageUI/src/client/context/LayoutEditorContextManager.tsx index 607b9115c..58f6ba7ad 100644 --- a/CageUI/src/client/context/LayoutEditorContextManager.tsx +++ b/CageUI/src/client/context/LayoutEditorContextManager.tsx @@ -58,11 +58,14 @@ import { getTranslation, isRackEnum, showLayoutEditorError, + checkAdjacent } from '../utils/LayoutEditorHelpers'; import * as d3 from 'd3'; import { + cageDirectionToModLocation, generateCageId, generateUUID, + getAdjLocation, getNextDefaultRackId, getSvgSize, parseLongId, @@ -289,6 +292,15 @@ export const LayoutEditorContextProvider: FC = ({children, p }); }; + const getNewGroupId = () => { + const newId = nextAvailGroup; + setNextAvailGroup(prevState => { + const nextId = parseLongId(prevState) + 1; + return `rack-group-${nextId}` as GroupId; + }); + return newId; + } + // This only adds default racks/cages to the layout, it is not used in loading in previous layouts const addRack = async (id: number, x: number, y: number, newScale: number, rackType: RackTypes): Promise => { const newCageNum: CageNumber = `${roomItemToString(rackType) as RackStringType}-${getNextCageNum(roomItemToString(rackType) as RackStringType)}`; @@ -367,7 +379,7 @@ export const LayoutEditorContextProvider: FC = ({children, p const newRackGroup: RackGroup = { selectionType: 'rackGroup', - groupId: nextAvailGroup, + groupId: getNewGroupId(), racks: [newRack], rotation: GroupRotation.Quarter, x: x, @@ -375,10 +387,6 @@ export const LayoutEditorContextProvider: FC = ({children, p scale: newScale, }; - setNextAvailGroup(prevState => { - const nextId = parseLongId(prevState) + 1; - return `rack-group-${nextId}` as GroupId; - }); setLocalRoom(prevRoom => ({ ...prevRoom, rackGroups: [...prevRoom.rackGroups, newRackGroup] @@ -844,7 +852,6 @@ export const LayoutEditorContextProvider: FC = ({children, p // 6. Split groups based on components let finalGroups = updatedGroups.filter(g => g.groupId !== location.rackGroup.groupId); - let nextGroupId = nextAvailGroup; // In the group splitting logic: if (components.size > 1) { @@ -948,15 +955,11 @@ export const LayoutEditorContextProvider: FC = ({children, p finalGroups.push({ ...affectedGroup, - groupId: nextGroupId, + groupId: getNewGroupId(), x: minX, y: minY, racks: newRacks }); - - // Update next group ID - const nextIdNum = parseInt(nextGroupId.split('-')[2]) + 1; - nextGroupId = `rack-group-${nextIdNum}` as GroupId; } } else { // No splitting needed, keep the modified group @@ -964,7 +967,6 @@ export const LayoutEditorContextProvider: FC = ({children, p } // 7. Update state - setNextAvailGroup(nextGroupId); setLocalRoom(prev => ({ ...prev, rackGroups: finalGroups @@ -1137,6 +1139,111 @@ export const LayoutEditorContextProvider: FC = ({children, p setCageNumChange({before: numBefore, after: numAfter}); }; + /* + Effectively unconnects the selectedRack from any connections with other racks. It does this by removing it from + the current rack group and creating a new rack group for the selected rack. + */ + const unmergeRacks = (rackGroup: RackGroup, selectedRack: Rack) => { + const newRoom: Room = { ...localRoom }; + + // 1. Find the index of the rack group that contains the selected rack + const rackGroupIndex = newRoom.rackGroups.findIndex(group => + group.groupId === rackGroup.groupId + ); + + if (rackGroupIndex === -1) return; + + const removedModIds: string[] = []; + const otherRacks = rackGroup.racks.filter(r => r.objectId !== selectedRack.objectId); + + // Process modifications between selectedRack and other racks in the group + selectedRack.cages.forEach(selectedCage => { + const selectedCageLoc = getCageLoc(selectedCage.svgId, selectedCage.cageNum); + if (!selectedCageLoc) return; + + otherRacks.forEach(otherRack => { + otherRack.cages.forEach(otherCage => { + const otherCageLoc = getCageLoc(otherCage.svgId, otherCage.cageNum); + if (!otherCageLoc) return; + + const adjResult = checkAdjacent(otherCageLoc, selectedCageLoc, selectedCage.size, otherCage.size); + if (adjResult.isAdjacent) { + const location = cageDirectionToModLocation(adjResult.direction, rackGroup.rotation); + const adjLocation = getAdjLocation(location); + + // Remove from selectedCage + if (selectedCage.mods && selectedCage.mods[location]) { + selectedCage.mods[location].forEach(mod => { + mod.modKeys.forEach(key => removedModIds.push(key.modId)); + }); + selectedCage.mods[location] = []; + } + + // Remove from otherCage + if (otherCage.mods && otherCage.mods[adjLocation]) { + otherCage.mods[adjLocation].forEach(mod => { + mod.modKeys.forEach(key => removedModIds.push(key.modId)); + }); + otherCage.mods[adjLocation] = []; + } + } + }); + }); + }); + + // Remove collected modIds from room.mods + if (newRoom.mods) { + removedModIds.forEach(id => { + delete newRoom.mods[id]; + }); + } + + // 2. Create the updated original rack group (without the selected rack) + let updatedOriginalRacks = otherRacks; + let updatedOriginalGroup = { ...rackGroup }; + + if (updatedOriginalRacks.length > 0) { + // Normalize the original group: find the new top-left corner + const minX = Math.min(...updatedOriginalRacks.map(r => r.x)); + const minY = Math.min(...updatedOriginalRacks.map(r => r.y)); + + updatedOriginalGroup = { + ...rackGroup, + x: rackGroup.x + minX, + y: rackGroup.y + minY, + racks: updatedOriginalRacks.map(r => ({ + ...r, + x: r.x - minX, + y: r.y - minY + })) + }; + newRoom.rackGroups[rackGroupIndex] = updatedOriginalGroup; + } else { + // If no racks left, remove the group entirely + newRoom.rackGroups.splice(rackGroupIndex, 1); + } + + // 3. Create the new rack group for the unmerged rack + // The new group starts at the global position of the selected rack + const newRackGroup: RackGroup = { + ...rackGroup, + groupId: getNewGroupId(), + x: rackGroup.x + selectedRack.x, + y: rackGroup.y + selectedRack.y, + racks: [{ + ...selectedRack, + x: 0, // Reset local coordinates to 0,0 in the new group + y: 0 + }] + }; + + // 4. Update the room state + newRoom.rackGroups = [...newRoom.rackGroups, newRackGroup]; + + setLocalRoom(newRoom); + setReloadRoom(newRoom); + }; + const getNextCageNum = (rackType: RackStringType) => { const cages = unitLocs[rackType]; @@ -1211,7 +1318,8 @@ export const LayoutEditorContextProvider: FC = ({children, p user, getAdjCages, reloadRoom, - setReloadRoom + setReloadRoom, + unmergeRacks }}> {!isLoading ? children : null} diff --git a/CageUI/src/client/pages/home/RoomHome.tsx b/CageUI/src/client/pages/home/RoomHome.tsx index 372707cda..d99dcf000 100644 --- a/CageUI/src/client/pages/home/RoomHome.tsx +++ b/CageUI/src/client/pages/home/RoomHome.tsx @@ -22,7 +22,7 @@ import '../../cageui.scss'; import { RoomList } from '../../components/home/RoomList'; import { RoomNavbar } from '../../components/home/RoomNavbar'; import { RoomContent } from '../../components/home/RoomContent'; -import { HomeNavigationContextProvider } from '../../context/HomeNavigationContextManager'; +import { HomeNavigationContextProvider, useHomeNavigationContext } from '../../context/HomeNavigationContextManager'; import { RoomContextProvider } from '../../context/RoomContextManager'; import { labkeyGetUserPermissions } from '../../api/labkeyActions'; import { GetUserPermissionsResponse } from '@labkey/api/dist/labkey/security/Permission'; @@ -45,7 +45,7 @@ export const RoomHome: FC = () => { return (user?.container && -
        +
        diff --git a/CageUI/src/client/types/homeNavigationContextTypes.ts b/CageUI/src/client/types/homeNavigationContextTypes.ts index 7f6d1a87d..816a746b8 100644 --- a/CageUI/src/client/types/homeNavigationContextTypes.ts +++ b/CageUI/src/client/types/homeNavigationContextTypes.ts @@ -33,4 +33,6 @@ export interface HomeNavigationContextType { selectedCage: Cage; navigateTo: (page: SelectedPage) => void; userProfile: GetUserPermissionsResponse; + setIsNavLoading: React.Dispatch>; + isNavLoading: boolean; } \ No newline at end of file diff --git a/CageUI/src/client/types/layoutEditorContextTypes.ts b/CageUI/src/client/types/layoutEditorContextTypes.ts index 74661d169..7fb6a245e 100644 --- a/CageUI/src/client/types/layoutEditorContextTypes.ts +++ b/CageUI/src/client/types/layoutEditorContextTypes.ts @@ -70,4 +70,5 @@ export interface LayoutContextType { getAdjCages: (cage: Cage, cageLoc: LocationCoords) => LocationCoords[]; reloadRoom: Room, setReloadRoom: React.Dispatch>, + unmergeRacks: (rackGroup: RackGroup, selectedRack: Rack) => void; } \ No newline at end of file diff --git a/CageUI/src/client/types/typings.ts b/CageUI/src/client/types/typings.ts index cd307d423..18076e486 100644 --- a/CageUI/src/client/types/typings.ts +++ b/CageUI/src/client/types/typings.ts @@ -69,6 +69,7 @@ export enum ModTypes { PCDivider = 'pcd', // protected contact VCDivider = 'vcd', // visual contact PrivacyDivider = 'pd', + LockedDivider = 'ld', NoDivider = 'nd', CTunnel = 'ct', Extension = 'ex', @@ -184,6 +185,9 @@ export type Modification = { export type ModRecord = Record; +export interface LoadedSvgs { + [key: RoomItemStringType]: SVGElement; +} export interface FetchRoomData { selectedSize: SelectorOptions; diff --git a/CageUI/src/client/utils/LayoutEditorHelpers.ts b/CageUI/src/client/utils/LayoutEditorHelpers.ts index d339357a0..0f8e4fa85 100644 --- a/CageUI/src/client/utils/LayoutEditorHelpers.ts +++ b/CageUI/src/client/utils/LayoutEditorHelpers.ts @@ -830,7 +830,7 @@ export function checkAdjacent(targetCage: LocationCoords, draggedCage: LocationC } } - return {isAdjacent: false, direction: '0'}; + return {isAdjacent: false, direction: null}; } //Offset for the top left corner of the layout, without doing this objects will randomly jump when dragging and placing diff --git a/CageUI/src/client/utils/constants.ts b/CageUI/src/client/utils/constants.ts index 8ef19e5cf..b28eef1e5 100644 --- a/CageUI/src/client/utils/constants.ts +++ b/CageUI/src/client/utils/constants.ts @@ -214,6 +214,20 @@ export const Modifications: ModRecord = { value: '4' }] }, + [ModTypes.LockedDivider]: { + name: 'Locked Divider', + svgIds: { + [ModLocations.Left]: LocationWithRotationMap[ModLocations.Left], + [ModLocations.Right]: LocationWithRotationMap[ModLocations.Right], + }, + styles: [{ + property: 'stroke', + value: '#ed1c24' + }, { + property: 'stroke-width', + value: '2' + }] + }, [ModTypes.NoDivider]: { name: 'No Divider', svgIds: { diff --git a/CageUI/src/client/utils/helpers.ts b/CageUI/src/client/utils/helpers.ts index 58555b31f..fff87219e 100644 --- a/CageUI/src/client/utils/helpers.ts +++ b/CageUI/src/client/utils/helpers.ts @@ -19,6 +19,7 @@ import { AllHistoryData, Cage, + CageDirection, CageModification, CageModificationsType, CageMods, @@ -32,7 +33,7 @@ import { GroupId, GroupRotation, LayoutData, - LayoutHistoryData, + LayoutHistoryData, LoadedSvgs, ModData, ModLocations, ModTypes, @@ -74,7 +75,7 @@ import { setupEditCageEvent } from './LayoutEditorHelpers'; import { SelectDistinctOptions } from '@labkey/api/dist/labkey/query/SelectDistinctRows'; -import { selectDistinctRows } from '@labkey/components'; +import { selectDistinctRows, selectRows } from '@labkey/components'; import { CELL_SIZE, Modifications, roomSizeOptions, SVG_HEIGHT, SVG_WIDTH } from './constants'; import { ExtraContext, LayoutSaveResult } from '../types/layoutEditorTypes'; import { SelectRowsOptions } from '@labkey/api/dist/labkey/query/SelectRows'; @@ -495,11 +496,45 @@ export const fetchRoomData = async (roomName: string, abortSignal?: AbortSignal) return prevRoomData; }; +const loadSvgs = async (): Promise => { + const loadedSvgs: LoadedSvgs = {}; + + const config: SelectRowsOptions = { + schemaName: "ehr_lookups", + queryName: "cageui_svg_urls", + columns: ["value", "title"] + } + + const res = await labkeyActionSelectWithPromise(config); + if(res.rowCount > 0){ + + // Create all promises first + const promises = res.rows.map(row => { + return d3.svg(`${ActionURL.getContextPath()}${row.title}`).then((d) => { + if(!loadedSvgs[row.value]){ // cage templates + loadedSvgs[row.value] = d.querySelector(`svg[id*=template]`); + } + if(!loadedSvgs[row.value]){ // room objects + loadedSvgs[row.value] = d.querySelector('svg'); + } + }); + }); + + // Wait for all promises to complete + await Promise.all(promises); + }else{ + console.error("Error finding cageUI Svgs") + } + + return loadedSvgs; +} + // Adds the svgs from the saved layouts to the DOM. Mode edit is version displayed in the layout editor and view is the one in the home views. // roomForMods is passed if the unitsToRender is not room but needs access to the room object. This is for loading mods. -export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | 'view', unitsToRender: Room | RackGroup | Rack | Cage, layoutSvg: d3.Selection, currRoom?: Room, modsToLoad?: RoomMods, setSelectedObj?, contextMenuRef?: MutableRefObject, setCtxMenuStyle?, closeMenuThenDrag?) => { +export const addPrevRoomSvgs = async (user: GetUserPermissionsResponse, mode: 'edit' | 'view', unitsToRender: Room | RackGroup | Rack | Cage, layoutSvg: d3.Selection, currRoom?: Room, modsToLoad?: RoomMods, setSelectedObj?, contextMenuRef?: MutableRefObject, setCtxMenuStyle?, closeMenuThenDrag?) => { let renderType: 'room' | 'group' | 'rack' | 'cage'; + const loadedSvgs: LoadedSvgs = await loadSvgs(); if ((unitsToRender as Room)?.rackGroups) { renderType = 'room'; @@ -553,16 +588,13 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | .style('pointer-events', 'bounding-box'); // This is where the cage svg group is created. - rack.cages.forEach(async (cage) => { + rack.cages.forEach((cage) => { const cageGroup = rackGroup.append('g') .attr('id', cage.svgId) .attr('name', cage.cageNum) .attr('transform', `translate(${cage.x},${cage.y})`); - let unitSvg: SVGElement; - await d3.svg(`${ActionURL.getContextPath()}/cageui/static/${rackTypeString}.svg`).then((d) => { - unitSvg = d.querySelector(`svg[id*=template]`); - }); + const unitSvg: SVGElement = loadedSvgs[rackTypeString].cloneNode(true) as SVGElement; // Only needed for layout editor to attach context menus const shape = d3.select(unitSvg); @@ -598,9 +630,9 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | .attr('id', group.groupId) .attr('class', 'draggable rack-group'); - group.racks.forEach(async rack => { + group.racks.forEach( rack => { // Use parent group as rackGroup if only 1 rack, otherwise create a new rack group - await createRackGroup(parentGroup, rack, isSingleRack, group.rotation); + createRackGroup(parentGroup, rack, isSingleRack, group.rotation); }); let groupX = renderType === 'room' ? group.x : group.racks[0].x; let groupY = renderType === 'room' ? group.y : group.racks[0].y; @@ -620,7 +652,7 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | }); // Render room objects - (unitsToRender as Room).objects.forEach(async (roomObj) => { + (unitsToRender as Room).objects.forEach( (roomObj) => { const wrapperGroup = layoutSvg.append('g') .attr('id', roomObj.itemId + '-wrapper') .attr('class', 'draggable room-obj') @@ -631,10 +663,7 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | .attr('id', roomObj.itemId) .attr('transform', `translate(0,0)`) - let objSvg: SVGElement; - await d3.svg(`${ActionURL.getContextPath()}/cageui/static/${roomItemToString(roomObj.type)}.svg`).then((d) => { - objSvg = d.querySelector('svg'); - }); + const objSvg: SVGElement = loadedSvgs[roomItemToString(roomObj.type)].cloneNode(true) as SVGElement; const shape = d3.select(objSvg) .classed('draggable', false) @@ -664,18 +693,15 @@ export const addPrevRoomSvgs = (user: GetUserPermissionsResponse, mode: 'edit' | const cageGroup = layoutSvg.append('g') .attr('id', cage.cageNum) .attr('transform', `translate(0,0)`); - let unitSvg: SVGElement; + const unitSvg: SVGElement = loadedSvgs[parseRoomItemType((unitsToRender as Cage).cageNum)].cloneNode(true) as SVGElement; - d3.svg(`${ActionURL.getContextPath()}/cageui/static/${parseRoomItemType((unitsToRender as Cage).cageNum)}.svg`).then((d) => { - unitSvg = d.querySelector(`svg[id*=template]`); - const shape = d3.select(unitSvg); - (shape.select('tspan').node() as SVGTSpanElement).textContent = `${parseRoomItemNum((unitsToRender as Cage).cageNum)}`; + const shape = d3.select(unitSvg); + (shape.select('tspan').node() as SVGTSpanElement).textContent = `${parseRoomItemNum((unitsToRender as Cage).cageNum)}`; - if (mode === 'view') { - loadCageMods(cage, shape, rackGroup.rotation); - } - cageGroup.append(() => shape.node()); - }); + if (mode === 'view') { + loadCageMods(cage, shape, rackGroup.rotation); + } + cageGroup.append(() => shape.node()); } }; @@ -865,7 +891,7 @@ export const buildNewLocalRoom = async (prevRoom: PrevRoom): Promise<[Room, Unit x: rackItem.xCoord - rack.x - group.x, // get cage coords by subtracting from both rack and group y: rackItem.yCoord - rack.y - group.y, size: svgSize, - mods: cageMods + mods: cageMods, }; newUnitLocs[cageNumType].push({ @@ -881,6 +907,7 @@ export const buildNewLocalRoom = async (prevRoom: PrevRoom): Promise<[Room, Unit const rackGroup: RackGroup = findOrAddGroup(rackItem); const rack: Rack = await findOrAddRack(rackGroup, rackItem); await addCageToRack(rack, rackItem, rackGroup); + }; // generates room object state for room objects from layout history data @@ -928,6 +955,55 @@ export const getAdjLocation = (loc: ModLocations): ModLocations => { } }; +export const cageDirectionToModLocation = (loc: CageDirection, rotation: GroupRotation): ModLocations => { + if(rotation === GroupRotation.Origin){ // 0 + switch (loc) { + case CageDirection.Left: + return ModLocations.Left; + case CageDirection.Right: + return ModLocations.Right; + case CageDirection.Top: + return ModLocations.Top; + case CageDirection.Bottom: + return ModLocations.Bottom; + } + }else if(rotation === GroupRotation.Quarter){ // 90 + switch (loc) { + case CageDirection.Left: + return ModLocations.Bottom; + case CageDirection.Right: + return ModLocations.Top; + case CageDirection.Top: + return ModLocations.Right; + case CageDirection.Bottom: + return ModLocations.Left; + } + }else if(rotation === GroupRotation.Half){ // 180 + switch (loc) { + case CageDirection.Left: + return ModLocations.Right; + case CageDirection.Right: + return ModLocations.Left; + case CageDirection.Top: + return ModLocations.Bottom; + case CageDirection.Bottom: + return ModLocations.Top; + } + }else if(rotation === GroupRotation.ThreeQuarter){ // 270 + switch (loc) { + case CageDirection.Left: + return ModLocations.Top; + case CageDirection.Right: + return ModLocations.Bottom; + case CageDirection.Top: + return ModLocations.Left; + case CageDirection.Bottom: + return ModLocations.Right; + } + } + +}; + export const getDefaultMod = (loc: ModLocations): ModTypes | null => { if (loc === ModLocations.Top || loc === ModLocations.Bottom) { return ModTypes.StandardFloor; @@ -1304,43 +1380,66 @@ export const saveRoomHelper = async (room: Room, sessionLog: SessionLog, oldTemp // Create default mods for new rooms. if (isRoomNonDefault) { - const usedMap = new Map(); room.rackGroups.forEach((group) => { group.racks.forEach((r) => { r.cages.forEach((c) => { - if (c.mods === undefined || c.mods === null) { - const connectedCages = findConnectedCages(r, group.rotation, c); - Object.entries(connectedCages).forEach(([direction, connections]) => { - if (connections.length === 0) { - return; + const connectedCages = findConnectedCages(r, group.rotation, c); + const connectedRacks = findConnectedRacks(group, r, c); + + // Combine all potential connection directions from both adjacent cages and racks + const allDirections = new Set([ + ...Object.keys(connectedCages), + ...Object.keys(connectedRacks) + ]); + + allDirections.forEach((direction) => { + const locDir = parseInt(direction) as ModLocations; + const cageConnections = connectedCages[locDir] || []; + const rackConnections = connectedRacks[locDir] || []; + + // Only proceed if there is a connection in this direction + if (cageConnections.length > 0 || rackConnections.length > 0) { + if (c.mods && c.mods[locDir] && c.mods[locDir].length > 0) { + // If existing mods exist for this direction, add them + c.mods[locDir].forEach(section => { + section.modKeys.forEach(key => { + newModData.push({ + cage: c.objectId, + location: locDir, + modId: key.modId, + modification: room.mods[key.modId].value, + parentModId: key.parentModId, + rack: r.objectId, + subId: section.subId, + }); + }); + }); + } else { + // If no mods exist for this connection, add default ones + if (cageConnections.length > 0) { + addModEntries(cageConnections, locDir, r, false, newModData, usedMap); + } + if (rackConnections.length > 0) { + addModEntries(rackConnections, locDir, r, true, newModData, usedMap); + } } - const locDir = parseInt(direction) as ModLocations; - addModEntries(connections, locDir, r, false, newModData, usedMap); - }); + } + }); - const connectedRacks = findConnectedRacks(group, r, c); - Object.entries(connectedRacks).forEach(([direction, connections]) => { - if (connections.length === 0) { - return; - } - const locDir = parseInt(direction) as ModLocations; - addModEntries(connections, locDir, r, true, newModData, usedMap); - }); - } else { - Object.entries(c.mods).forEach(([direction, modSubsections]: [string, CageModification[]]) => { - modSubsections.forEach(section => { - section.modKeys.forEach(key => { - newModData.push({ - cage: c.objectId, - location: parseInt(direction), - modId: key.modId, - modification: room.mods[key.modId].value, - parentModId: key.parentModId, - rack: r.objectId, - subId: section.subId, - }); + // Handle Direct location mods (not used in connections) + if (c.mods && c.mods[ModLocations.Direct] && c.mods[ModLocations.Direct].length > 0) { + c.mods[ModLocations.Direct].forEach(section => { + section.modKeys.forEach(key => { + newModData.push({ + cage: c.objectId, + location: ModLocations.Direct, + modId: key.modId, + modification: room.mods[key.modId].value, + parentModId: key.parentModId, + rack: r.objectId, + subId: section.subId, }); }); }); diff --git a/CageUI/src/org/labkey/cageui/CageUIManager.java b/CageUI/src/org/labkey/cageui/CageUIManager.java index ff7804ce4..c01e49eb8 100644 --- a/CageUI/src/org/labkey/cageui/CageUIManager.java +++ b/CageUI/src/org/labkey/cageui/CageUIManager.java @@ -965,17 +965,12 @@ private void submitTemplateLayout(Room room, String historyId, BundledForms bund ArrayList templateForms = new ArrayList<>(); // Process rack groups - int rackGroupIndex = 0; for (RackGroup rackGroup : room.getRackGroups()) { - rackGroupIndex++; // Process racks in this group - int rackIndex = 0; for (Rack rack : rackGroup.getRacks()) { - rackIndex++; - // Process cages in this rack if (rack.getCages() != null) { @@ -983,9 +978,9 @@ private void submitTemplateLayout(Room room, String historyId, BundledForms bund { TemplateLayoutHistoryForm form = new TemplateLayoutHistoryForm(); form.setHistoryId(historyId); - form.setRackGroup(rackGroupIndex); + form.setRackGroup(findLastNumberAfterDash(rackGroup.getGroupId())); form.setGroupRotation(rackGroup.getRotation()); - form.setRack(rackIndex); + form.setRack(rack.getItemId()); form.setCage(findLastNumberAfterDash(cage.getCageNum())); form.setObjectType(rack.getType().getEffectiveRackType().getNumericValue()); form.setExtraContext(cage.getExtraContext() != null ? diff --git a/CageUI/src/org/labkey/cageui/CageUIModule.java b/CageUI/src/org/labkey/cageui/CageUIModule.java index 6bde122a0..0ccbe402a 100644 --- a/CageUI/src/org/labkey/cageui/CageUIModule.java +++ b/CageUI/src/org/labkey/cageui/CageUIModule.java @@ -59,7 +59,7 @@ public String getName() @Override public @Nullable Double getSchemaVersion() { - return 25.003; + return 26.001; } @Override diff --git a/CageUI/src/org/labkey/cageui/model/ModTypes.java b/CageUI/src/org/labkey/cageui/model/ModTypes.java index 6497ec2ef..6eb02f82a 100644 --- a/CageUI/src/org/labkey/cageui/model/ModTypes.java +++ b/CageUI/src/org/labkey/cageui/model/ModTypes.java @@ -31,6 +31,7 @@ public enum ModTypes PCDivider("pcd"), VCDivider("vcd"), PrivacyDivider("pd"), + LockedDivider("ld"), NoDivider("nd"), CTunnel("ct"), Extension("ex"), diff --git a/WNPRC_EHR/resources/data/cageui_svg_urls.tsv b/WNPRC_EHR/resources/data/cageui_svg_urls.tsv new file mode 100644 index 000000000..ec3a6157e --- /dev/null +++ b/WNPRC_EHR/resources/data/cageui_svg_urls.tsv @@ -0,0 +1,12 @@ +Value Title Category Description Sort Order Date Disabled +bottom /cageui/static/bottom.svg +cage /cageui/static/cage.svg +door /cageui/static/door.svg +drain /cageui/static/drain.svg +gateClosed /cageui/static/gateClosed.svg +gateOpen /cageui/static/gateOpen.svg +pen /cageui/static/pen.svg +playCage /cageui/static/pen.svg +roomDivider /cageui/static/roomDivider.svg +tempCage /cageui/static/cage.svg +top /cageui/static/top.svg \ No newline at end of file diff --git a/WNPRC_EHR/resources/data/lookup_sets.tsv b/WNPRC_EHR/resources/data/lookup_sets.tsv index 207704850..79aeddff6 100644 --- a/WNPRC_EHR/resources/data/lookup_sets.tsv +++ b/WNPRC_EHR/resources/data/lookup_sets.tsv @@ -14,6 +14,7 @@ blood_billed_by Blood Billed By Field Values value title blood_code_prefixes Blood Code Prefix Field Values value cageui_item_types Room Item Type Field Values value cageui_rack_manufacturers Rack Manufacturer Field Values value +cageui_svg_urls SVG URLS Field Values value chemistry_method Chemistry Method Field Values value chow_types Chow Types Field Values value clinpath_collection_method Clinpath Collection Method Field Values value diff --git a/WNPRC_EHR/resources/views/populateInitialData.html b/WNPRC_EHR/resources/views/populateInitialData.html index db850fad9..561d28778 100644 --- a/WNPRC_EHR/resources/views/populateInitialData.html +++ b/WNPRC_EHR/resources/views/populateInitialData.html @@ -283,6 +283,13 @@ queryName: 'cageui_rack_manufacturers', module: 'wnprc_ehr', pk: 'value' + },{ + label: 'SVG URLS Field Values', + populateFn: 'populateFromFile', + schemaName: 'ehr_lookups', + queryName: 'cageui_svg_urls', + module: 'wnprc_ehr', + pk: 'value' }]; tables.sort(function(a, b) { diff --git a/WNPRC_EHR/resources/views/wnprcLabs.html b/WNPRC_EHR/resources/views/wnprcLabs.html index 13394906c..6cdcbd310 100644 --- a/WNPRC_EHR/resources/views/wnprcLabs.html +++ b/WNPRC_EHR/resources/views/wnprcLabs.html @@ -18,7 +18,9 @@ {name: 'Jenna Schmidt Lab**', url: '<%=contextPath%>' + '/WNPRC/WNPRC_Laboratories/golos/project-begin.view'}, {name: 'Shelby O\'Connor Lab**', url: '<%=contextPath%>' + '/WNPRC/WNPRC_Laboratories/soconnor/project-begin.view'}, {name: 'Slukvin Lab**', url: '<%=contextPath%>' + '/WNPRC/WNPRC_Laboratories/Slukvin/project-begin.view'}, - {name: 'Colman Lab**', url: '<%=contextPath%>' + '/WNPRC/WNPRC_Laboratories/Colman/project-begin.view'} + {name: 'Colman Lab**', url: '<%=contextPath%>' + '/WNPRC/WNPRC_Laboratories/Colman/project-begin.view'}, + {name: 'Carrion Lab**', url: '<%=contextPath%>' + '/project/WNPRC/WNPRC_Laboratories/Carrion/begin.view'}, + {name: 'Evans Lab**', url: '<%=contextPath%>' + '/project/WNPRC/WNPRC_Laboratories/Evans/begin.view'} ]}, {header: 'Affiliated Groups', items: [ diff --git a/docker/README.md b/docker/README.md index d71c10987..d32aca3ba 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,64 +1,72 @@ # Creating and Using Docker Images -This folder contains a set of Docker images and a Docker Compose service definition to start and run a LabKey server like the one used at the WNPRC. Each of the subfolders corresponds to a particular service/image used in the Compose definition (e.g., `postgres/` contains configuration information for the PostgreSQL service), and the Gradle build file helps to build the custom images that do not come from any online Docker repository (such as LabKey and our own custom cron service). +This folder contains a set of folders with Dockerfiles and a Compose file which define services to start and run a LabKey server like the one used at the WNPRC. Each of the subfolders corresponds to a particular service/image used in the Compose definition (e.g., `postgres/` contains configuration information for the PostgreSQL service), and the Gradle build file helps to build the custom images that do not come from any online Docker repository (such as LabKey and our own custom cron service). -Any service-specific configuration needs to be defined in a `.env` file in this directory, with a pre-built example file provided in `default.env`. All the variables in the `default.env` file has a prefix to the corresponding service (e.g. LK = labkey, PG = postgres) and they are all organized alphabetically to make it easier to group all variables that affect the different services. Before deploying the services with Compose, you will need to create this `.env` file (e.g., by copying and renaming `default.env`). +Any service-specific configuration needs to be defined in a `.env` file in this directory, with a pre-built example file provided in `default.env`. All the variables in the `default.env` file have a prefix to the corresponding service (e.g. LK = LabKey, PG = postgres) and they are all organized alphabetically to make it easier to group all variables that affect the different services. Before deploying the services with Compose, you will need to create the `.env` file (e.g., by copying and renaming `default.env`). The following files need to be rename to use SSL certificates in your local development machine: `cert.pem.default` and `key.pem.default` both files have to be rename to remove the .default . The names have to match the names in `.env` file. ## Downloading Docker Images from Docker Hub -WNPRC maintains a service contract with Docker Hub. This allows the IDS unit to build images in this cloud service thus not requiring to locally build images in our production server, test environment and developer machines. The contract allows for five accounts to be associated with the WNPRCEHR Organization. The `idsshared` account can be used to download and access our private LabKey images (i.e. [labkeysnapshot](https://hub.docker.com/repository/docker/wnprcehr/labkeysnapshot/general) and [labkey](https://hub.docker.com/repository/docker/wnprcehr/labkey/general)), the token and password for that account can be found in `Keypass-IDS.kdbx` in the `wnprc.dirve.wisc.edu` shared folder. +WNPRC maintains a service contract with Docker Hub. This contract allows the IDS unit to build images in this cloud service thus not requiring to locally build images in our production server, test environment and developer machines. The contract allows for five accounts to be associated with the WNPRCEHR Organization. The `idsshared` account can be used to download and access our private LabKey images (i.e. [labkeysnapshot](https://hub.docker.com/repository/docker/wnprcehr/labkeysnapshot/general) and [labkey](https://hub.docker.com/repository/docker/wnprcehr/labkey/general)), the token and password for that account can be found in `Keypass-IDS.kdbx` in the `wnprc.drive.wisc.edu` shared folder. -Another alternative is to login via the Docker CLI (`docker login`) with the shared username and password. Gradle tasks can login to Docker Hub without the need to type the password. - -All the docker images can be downloaded from Docker Hub using the following commands, user has to be login into Docker Hub. It is best to use a token and/or a password saved on the user's home folder file called `~/.gradle/gradle.properties` by adding the following lines. +Another alternative is to login via the Docker CLI (`docker login`) with the shared username and password. Gradle tasks can login to Docker Hub without the need to type the password but the credentials need to be stored in the gradle.properties. It is best to use a token and/or a password saved on the user's home folder file called `~/.gradle/gradle.properties`, this is the same file used during the LabKey development setup. Add the following lines replacing the data inside brackets. ``` dockerhubUsername=idsshared dockerhubPassword= dockertokenpath= ``` +For a list of all the task use the following commands: + +``` +./gradlew tasks +``` -Gradle tasks to interact with Docker have two versions, one using a [plugin](https://github.com/bmuschko/gradle-docker-plugin) and the second one uses direct command line via the Docker CLI. All the tasks defined in the `build.gradle` file have the two flavors. Either of the following command downloads all the custom images manage by the IDS unit. +Docker images can be downloaded from Docker Hub using the following commands, user has to be logged into Docker Hub as explained in the previous chapter. All Gradle tasks to interact with the Docker engine locally have two versions, one using a [plugin](https://github.com/bmuschko/gradle-docker-plugin) and the second one uses direct command line via the Docker CLI. Thus, all the tasks defined in the `build.gradle` file have two versions. Either of the following commands download all the custom images managed by the IDS unit. ``` ./gradlew downloadAll ./gradlew downloadAllPlug ``` -To download a specific images from a feature branch use the following commands replacing the Labkey version (i.e. XX.YY = 22.11) and the name of the branch inside the brackets: -``` -./gradlew downloadLabkey -PdockerString= -./gradlew dowloadLabkeyPlug -PdockerString= - -./gradlew downloadEhrcron -PdockerString= -./gradlew downloadEhrcronPlug -PdockerString= +To download a specific image from a feature branch use the following commands replacing the Labkey version (i.e. XX.YY = 24.11) and the name of the branch inside the brackets: ``` +./gradlew downloadLabkey -PbranchName= +./gradlew dowloadLabkeyPlug -PbranchName= -For a list of all the task use the follwowing commands: - -``` -./gradlew tasks +./gradlew downloadEhrcron -PbranchName= +./gradlew downloadEhrcronPlug -PbranchName= ``` ## Building the Custom Images To build the custom images from a stand-alone clone, navigate to the **docker** folder (**not** the repository root) and execute the following command: ``` -./gradlew buildAll -PdockerString= +./gradlew buildAll -PbranchName= ``` -From a clone embedded inside a LabKey platform source code, you will need to execute the command from the LabKey root, with the appropriate adjustments to the project path: +From a clone embedded inside a LabKey development setup with all the source code, you will need to execute the command from the LabKey root, with the appropriate adjustments to the project path: ``` -./gradlew :externalModules:wnprc-modules:docker:buildall -PdockerString= +./gradlew :externalModules:wnprc-modules:docker:buildall -PbranchName= ``` -Each of the custom images has its own build task as well (e.g., `buildLabkey`, `buildEhrcron`) and all have corresponding tasks using the pluging (e.g. `buildEhrcronPlug`, `buildPostfixPlug`). The Labkey images depends on a hook (`docker/labkey/hooks/build`) which is used in Docker Hub to correctly interprete GitHub branches naming convencion. This same hook is used by the gradle task to download the correct LabKey installer and create the Docker image. This build tasks does not have a companion option using the plugin. +Each of the custom images has its own build task as well (e.g., `buildLabkey`, `buildEhrcron`) and all have corresponding tasks using the pluging (e.g. `buildEhrcronPlug`, `buildPostfixPlug`). The Labkey and ehrcranrnutils images depend on hooks (`~/hooks/build`) which is used in Docker Hub to correctly interpret GitHub branches naming convencion and build the image for the correct architecture (i.e., arm64 and adm64). This same hook is used by the gradle task to download the correct LabKey installer from TeamCity and create the corresponding Docker image. These build tasks does not have a companion option using the plugin version. + +For newer Apple Silicon all docker images can be built for ARM processors or as multi-platform builds by using the platform argument. Before building a multi-platform version a new builder has to be created. +``` +docker buildx create --name=container +``` +This builder uses an emulator to create images for AMD processor. In the Apple Silicon the build process for AMD images take longer therefore is best to just use arm64 or the default builder. +``` +--platform linux/arm64 +--platform linux/arm64,linux/amd64 +``` Other than using Gradle, the images can each be built directly using Docker by executing a command like this: ``` docker build -t wnprcehr/ehrcron:vX.X.X ehrcron +docker build --builder container --platform linux/arm64 -t wnprcehr/cranrnutils:cranrnutils_YY.MM_featureBranch --load cranrnutils ``` -If changes are only committed to TeamCity or a new based LabKey build needs to be create, use --no-cache option. To build localy, you must obtain the URL to download the installer from TeamCity. The Dockerfile connect to TeamCity using a set of credentials and downloads the LabKey installer. +If changes are only committed to TeamCity or a new based LabKey build needs to be created, use --no-cache option. To build localy, you must obtain the URL to download the installer from TeamCity. The Dockerfile connects to TeamCity using a set of credentials and downloads the LabKey installer. ``` docker build --build-arg LABKEY_TEAMCITY_USERNAME= --build-arg LABKEY_TEAMCITY_PASSWORD= --build-arg TEAMCITY_URL= --build-arg TOMCAT_IMAGE= --build-arg LK_VERSION= --no-cache --rm=true -t wnprcehr/labkey:XX.YY labkey ``` @@ -85,12 +93,16 @@ labkeyTeamcityPassword= To build using Docker directly, you will need to pass those same credentials as run-time build arguments on the command line: ``` -docker build \ - --build-arg LABKEY_TEAMCITY_USERNAME= \ - --build-arg LABKEY_TEAMCITY_PASSWORD= \ - -t wnprcehr/labkey:XX.X labkey +docker build \ +--builder container --platform linux/arm64,linux/amd64 \ +--build-arg LABKEY_TEAMCITY_USERNAME= \ +--build-arg LABKEY_TEAMCITY_PASSWORD= \ +--build-arg TEAMCITY_URL= \ +--build-arg FB_NAME= \ +--build-arg LK_VERSION= --no-cache --rm=true --load \ +-t wnprcehr/labkey:YY.MM_Feature_Branch labkey ``` -If you want to build an images for an specific branch within Github, you should pass one additional argument `--build-arg TOMCAT_IMAGE`. Your commands will look something like this, use the name of the branch without the fb prefix, the name should match as how TeamCity creates the image: +If you want to build an image for a specific branch within Github, you should pass one additional argument `--build-arg TOMCAT_IMAGE`. Your commands will look something like this, use the name of the branch without the fb prefix, the name should match as how TeamCity creates the image: ``` docker build \ --build-arg LABKEY_TEAMCITY_USERNAME= \ @@ -99,22 +111,22 @@ docker build \ -t wnprcehr/labkeyDev:XX.X labkey ``` -The LabKey image depends on the Tomcat image, which can be dowload from Docker Hub or build locally. This image takes a long time to build from scratch, it is best to download it from Docker Hub. Here are the commands to download or build this image. +The LabKey image depends on the Tomcat image, which can be dowloaded from Docker Hub or built locally. This image takes a long time to build from scratch, it is best to download it from Docker Hub. Here are the commands to download or build this image. ``` -./gradlew downloadTomcat -PbranchName= +./gradlew downloadCranr -PbranchName= ./gradlew downloadTomcatPlug -PbranchName= -./gradlew buildTomcat -PbranchName= -./gradlew buildTomcatPlug -PbranchName= +./gradlew buildCranr -PbranchName= +./gradlew buildCranrPlug -PbranchName= docker build --no-cache -t wnprcehr/tomcat:tomcat9_ tomcat ``` ## Deploying the Docker Compose Services -There are several services controlled by the compose.yaml and production.yaml files. Spliting the Docker services in these two files allows to use the same GitHub repository in two different server without having to make changes locally except for changes in the `.env` file. +There are several services controlled by the compose.yaml and production.yaml files. Splitting the Docker services in these two files allows us to use the same GitHub repository in two different servers without having to make changes locally except for changes in the `.env` file. -The Docker services are production EHR, nightly-ehr and test servers ran are the follwoing: +The Docker services ran in production EHR, nightly-ehr and test servers are the following: ||Service|Functionality|YAML File|Repository| |---|---|---|---|---| |1|postgres|database|compose.yaml|[postgres](https://hub.docker.com/_/postgres)| @@ -137,7 +149,7 @@ To deploy the services, you again either use Gradle or use Docker Compose direct # for tearing down all the services ./gradlew :docker:down ``` -To use Docker Compose, you can execute commands like the following (*from this directory*, where your `.env` file is located), this commands will work on production server as well as other servers: +To use Docker Compose, you can execute commands like the following (*from this directory*, where your `.env` file is located), these commands will work on the production server as well as the other servers: ``` # for spinning up all the services in production server docker compose -f compose.yaml -f production.yaml up -d @@ -146,7 +158,7 @@ docker compose -f compose.yaml -f production.yaml up -d docker compose -f compose.yaml -f production.yaml down --timeout 60 ``` -Add `-f compose.yaml -f production.yaml` to make changes in the production server. If this is not added the system will provide a warning that there is an orphan services running. +Add `-f compose.yaml -f production.yaml` to make changes in the production server. If this is not added the system will provide a warning that there are orphan services running. ``` # for spinning up all the services docker compose up -d @@ -160,12 +172,15 @@ docker compose up -d postgres # for taking down just one of the services (e.g., postgres) docker compose stop postgres --timeout 60 +# for removing just one of the services this makes sure the system uses the latest version (e.g., postgres) +docker compose rm postgres --timeout 60 + # for accesing running services to inspect changes use the following commands (e.g., labkey or postgres) docker compose exec labkey /bin/bash ``` All other Docker Compose commands (`logs`, `ps`, etc.) work also. -*Note that sometimes the postgres container closes before the database itself is completely shut down. Be sure to disconnect your pgAdmin and IntelliJ database connections, if any, stop labkey, and then do a shutdown. Otherwise the next time postgres starts it will go into an automatic recovery mode and take a long time to start back up. By adding a timeout of 60 seconds it allows the database to close gracefully and avoid the recovery process. +*Note that sometimes the postgres container closes before the database itself is completely shut down. Be sure to disconnect your pgAdmin and IntelliJ database connections, if any, stop labkey, and then do a shutdown. Otherwise the next time postgres starts it will go into an automatic recovery mode and take a long time to start back up. By adding a timeout of 60 seconds it allows the database to close every connection, shutdown gracefully and avoid the recovery process. ## Docker setup in production EHR Running EHR in the production mode, requires different images for ehrcron and the **SNAPSHOT** version of LabKey. The verison of LabKey is controlled by the following variables stored in `.env` file: `LK_PROD`, `LK_VERSION` and `LK_FB`. These varaibles get replace in the following string `wnprcehr/labkey${LK_PROD}:$LK_VERSION${LK_FB}` during runtime. The string becomes `wnprcehr/labkeysnapshot:24.11` which are the tags in this [repo](https://hub.docker.com/repository/docker/wnprcehr/labkeysnapshot/tags). The version of ehrcron image is also controlled by the `.env` file. The variables `PERL_PROD`, `LK_VERSION` and `LK_FB` are used to modify the name of the image defined in the `production.yaml` file from `wnprcehr/ehrcron$PERL_PROD:$LK_VERSION${LK_FB}` to `wnprcehr/ehrcronprod:24.11`. These are the tags defined in this [repo](https://hub.docker.com/repository/docker/wnprcehr/ehrcronprod/general). The ehrcronprod image has the following scheduled jobs: @@ -176,23 +191,23 @@ Running EHR in the production mode, requires different images for ehrcron and t ## Running multiple instances of LabKey in same Server -We created a folder called `development` in this repo. This folder contains a simplify version of the main `compose.yaml` file. It only has two services: labkey and ngnix. To start a secondary version of labkey in the test server. Copy the development folder, and rename it to particular project. Within the new folder, you have to edit three of files: +We created a folder called `development` in this repo. This folder contains a simplify version of the main `compose.yaml` file. It only has two services: labkey and ngnix. To start a secondary version of labkey in the test server. Copy the development folder, and rename it to particular project. Within the new folder, you have to edit three files: 1. `.env` 1. `nginx/nginx.conf` 1. `compose.yaml` -In the `.env` file, edit the following variables: `LK_DANGER_PORT` to other number than 8080, this is the port which Labkey service will use outside the Docker container. `LK_SECURE_PORT` this port is the one user will need to add to the test server URL to access your instance of LabKey (e.g. https://.primate.wisc.edu:8443). List of ports and databases used for each instance of LabKey in the test-server can be found in this private page: [Test_Servers](https://github.com/WNPRC-EHR-Services/EHR_Documentation/blob/master/sop/Test_Servers.md). Update the list once your instance is up and running. `LK_BASE_URL` to a unique name for your new labkey service, it has to match the name you will modify in the `compose.yaml` file. `PG_NAME` to a database you are planning to use with your new instances of LabKey. +In the `.env` file, edit the following variables: `LK_DANGER_PORT` to a number other than 8080, this is the port which LabKey service will use outside the Docker container. `LK_SECURE_PORT` this port is the one users will need to add to the test server URL to access your instance of LabKey (e.g. https://.primate.wisc.edu:8443). List of ports and databases used for each instance of LabKey in the test-server can be found in this private page: [Test_Servers](https://github.com/WNPRC-EHR-Services/EHR_Documentation/blob/master/sop/Test_Servers.md). Update the list once your instance is up and running. `LK_BASE_URL` to a unique name for your new LabKey service, it has to match the name you will modify in the `compose.yaml` file. `PG_NAME` to a database you are planning to use with your new instances of LabKey. In the `ngnix.conf` file you need to edit the following: `proxy_pass` at the end of the file, to the name you have selected for your new service, it also has to match the name on your `compose.yaml` and `.env` files. -Finally, in your `compose.yaml` file edit the name of the labkey service, it should be unique, therefore check other development folder for all the names used. +Finally, in your `compose.yaml` file edit the name of the LabKey service, it should be unique, therefore check other development folders for all the names used. -All the auxiliary LabKey instances can be manage via the manage_all_continers.sh script. This script accepts two values (-s || -d), s starts all the containers in the docker folder. Starts with the primary which contains postgres and than looks for any folder that starts with dev. +All the auxiliary LabKey instances can be managed via the manage_all_continers.sh script. This script accepts two values (i.e., -s || -d), `-s` - starts all the containers in the docker folder and `-d` - shuts down all the instances running in the server. This script starts with the primary which contains postgres and than looks for any folder that has the prefix dev. ## Loading a Database Backup Using the Script -Along with the Docker-specific utilities in this folder, there is a (Bash-only) script to restore a database backup into a local Docker container: **load_database_backup.sh**. By default, this script will download the latest backup from the production server (assumed to have been created the same day at 1AM) and restore that backup into a PostgreSQL container as defined in the docker-compose.yml and .env files in this folder. Depending on resource on local machine or server, it is possible to increase the number of processors for the restore process. Change the number in line 132 right after -j option, by default is set to 4 processes. +Along with the Docker-specific utilities in this folder, there is a (Bash-only) script to restore a database backup into a local Docker container: **load_database_backup.sh**. By default, this script will download the latest backup from the production server (assumed to have been created the same day at 1AM) and restore that backup into a PostgreSQL container as defined in the docker-compose.yml and .env files in this folder. Depending on the resources on the local machine or server, it is possible to increase the number of processors for the restore process. Change the number in line 132 right after -j option, which by default is set to 4 processes. The script has very few options, as shown in these examples: ```bash @@ -211,6 +226,16 @@ The script has very few options, as shown in these examples: ``` The use of the `-p` flag allows us to use this script to manage multiple instances of the LabKey PostgreSQL container on the same server, provided that each instance is run from its own folder with its own .env file (to specify ports, data file locations, etc.) +## Configuration of nightly-ehr.primate.wisc.edu + +This server is configured to update every night after the production server completes a complete backup and moves the created file to a long term ITSS storage (i.e., `PrimateFS`). The script called `load_database_update_testserver.sh` is based on `load_database_backup.sh` and it is configured to run as a cron job in the `nightly-ehr.primate.wisc.edu` server by the root user. + +To check the current configuraion type: `sudo crontab -l`. To modify the configuration type: `sudo crontab -e`. + +The script uses multiple parameters: `-postgres` - location of postgres executable (i.e., /usr/lib/postgresql/15/bin/), `--dbname` - name of the database to replace, `--jobs` - number of processes to run the backup, `--production` - restore a complete database, `--path` - location of the backup files (~/labkey_backup/database/daily/). + +The script also downloads the latest image of LabKeySnapshot from Docker Hub and cleans all the old images from the local image repository. + ## Additional Configurations In some instance, the shared memory and effective cache size should be modified for dev machines. In the docker/postgres/postgresql.cong file modify line shared_buffers and effective_cache_size to 1024MB and 2048MB respectively. diff --git a/docker/build.gradle b/docker/build.gradle index 0bdd08834..d31ae1a5c 100644 --- a/docker/build.gradle +++ b/docker/build.gradle @@ -98,7 +98,7 @@ tasks.register("buildEhrCronPlug", DockerBuildImage){ group 'DockerPlugin' } -tasks.register("buildTomcat") { Task t -> +tasks.register("buildCranr") { Task t -> doLast { if (!project.hasProperty("nocache")){ ext.nocache = "false" @@ -125,32 +125,32 @@ tasks.register("buildTomcat") { Task t -> if (nocache.equals("true")){ println "building image without cache" t.project.exec{ - workingDir "$projectDir/tomcat" + workingDir "$projectDir/cranrnutils" executable "docker" - args "build", ".", "--no-cache", "-t", "wnprcehr/tomcat:tomcat9_"+branch + args "build", "--platform","linux/arm64,linux/amd64" ,"--no-cache", "-t", "wnprcehr/cranrnutils:cranrnutils_"+branch, "." } }else{ t.project.exec{ - workingDir "$projectDir/tomcat" + workingDir "$projectDir/cranrnutils" executable "docker" - args "build", ".", "-t", "wnprcehr/tomcat:tomcat9_"+branch + args "build", "--platform","linux/arm64,linux/amd64" ,"-t", "wnprcehr/cranrnutils:cranrnutils_"+branch, "." } } }else { - logger.warn 'Must provide docker string via command line. (ex. ./gradlew buildTomcat -PbranchName=22.11_fb)' + logger.warn 'Must provide docker string via command line. (ex. ./gradlew buildCranr -PbranchName=22.11_fb)' } } } -configure(buildTomcat){ +configure(buildCranr){ group 'Docker' description 'Generates the Tomcat docker image (with R)' dependsOn "checkDocker", "dockerLogin", "processbranchName" onlyIf {buildType != ''} } -tasks.register("buildTomcatPlug", DockerBuildImage){ +tasks.register("buildCranrPlug", DockerBuildImage){ def dockerStr = providers.gradleProperty("branchName") if (dockerStr.present){ // String branch = "" @@ -169,13 +169,13 @@ tasks.register("buildTomcatPlug", DockerBuildImage){ break; } inputDir.set(file("tomcat/")) - images.add("wnprcehr/tomcat:tomcat9_${branch}") + images.add("wnprcehr/cranrnutils:cranrnutils_${branch}") }else { - logger.warn 'Must provide docker string via command line. (ex. ./gradlew buildTomcatPlug -PbranchName=22.11_fb)' + logger.warn 'Must provide docker string via command line. (ex. ./gradlew buildCranrPlug -PbranchName=22.11_fb)' } } -configure(buildTomcatPlug){ +configure(buildCranrPlug){ group 'DockerPlugin' description 'Generates the Tomcat docker image (with R)' dependsOn "checkDocker", "dockerLogin", "processbranchName" @@ -184,7 +184,7 @@ configure(buildTomcatPlug){ //Building LabKey with hooks/build for consistancy with Docker Hub and used of environment variables duirng the build process //Source: https://stackoverflow.com/questions/36322536/how-to-set-an-environment-variable-from-a-gradle-build / https://stackoverflow.com/a/63140816 tasks.register("buildLabkey") { Task t -> -//task("buildLabkey", group: "Docker", description: "Generates the LabKey docker image", dependsOn: ["buildTomcat", "buildEhrCron", "buildPostfix"]) { Task t -> +//task("buildLabkey", group: "Docker", description: "Generates the LabKey docker image", dependsOn: ["buildCranr", "buildEhrCron", "buildPostfix"]) { Task t -> doLast { def dockerStr = providers.gradleProperty("branchName") @@ -317,7 +317,7 @@ configure(tagPostfixPlug){ tasks.register("buildAll"){ group 'Docker' description 'Generates all the docker images in the subfolders' - dependsOn "buildTomcat", "buildEhrcron", "buildLabkey", "buildPostfix" + dependsOn "buildCranr", "buildEhrcron", "buildLabkey", "buildPostfix" } @@ -445,7 +445,7 @@ tasks.register("downloadPostfixPlug",DockerPullImage){ dependsOn 'checkDocker' } -tasks.register("downloadTomcat"){ +tasks.register("downloadCranr"){ doLast { def dockerStr = providers.gradleProperty("branchName") if (dockerStr.present) @@ -454,11 +454,11 @@ tasks.register("downloadTomcat"){ String branchName = dockerStr.get() t.project.exec { executable "docker" - args "pull", "wnprcehr/tomcat:tomcat9_${branchName}" + args "pull", "wnprcehr/cranrnutils:cranrnutils_${branch}" } } else{ - logger.warn 'Must provide docker string via command line. (ex. ./gradlew downloadTomcat -PbranchName=22.11_fb)' + logger.warn 'Must provide docker string via command line. (ex. ./gradlew downloadCranr -PbranchName=22.11_fb)' } } group 'Docker' @@ -467,8 +467,8 @@ tasks.register("downloadTomcat"){ } -tasks.register("downloadTomcatPlug",DockerPullImage){ - image = "wnprcehr/tomcat:tomcat9_${branchName}" +tasks.register("downloadCranrPlug",DockerPullImage){ + image = "wnprcehr/cranrnutils:cranrnutils_${branch}" onComplete{ def dockerStr = providers.gradleProperty("branchName") @@ -478,7 +478,7 @@ tasks.register("downloadTomcatPlug",DockerPullImage){ } else{ - logger.warn 'Must provide docker string via command line. (ex. ./gradlew downloadTomcatPlug -PbranchName=22.11_fb)' + logger.warn 'Must provide docker string via command line. (ex. ./gradlew downloadCranrPlug -PbranchName=22.11_fb)' } } doLast{ diff --git a/docker/cranrnutils/Dockerfile b/docker/cranrnutils/Dockerfile index b1ab16e88..82fdb2460 100644 --- a/docker/cranrnutils/Dockerfile +++ b/docker/cranrnutils/Dockerfile @@ -1,4 +1,4 @@ -FROM eclipse-temurin:17-jre-jammy +FROM eclipse-temurin:25-jre-noble # Set noninteractive mode for apt-get ENV DEBIAN_FRONTEND=noninteractive @@ -7,37 +7,35 @@ ENV LANG=en_US.utf8 RUN apt-get update \ && apt-get -qq install -y lsb-release \ - software-properties-common \ + software-properties-common \ + curl \ && apt-get clean -# Download and add siging key for Postgres 15 -# https://www.linuxtechi.com/how-to-install-postgresql-on-ubuntu/ -# https://askubuntu.com/a/1456015 -RUN echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list -RUN wget -qO- https://www.postgresql.org/media/keys/ACCC4CF8.asc | tee /etc/apt/trusted.gpg.d/pgdg.asc > /dev/null 2>&1 +# Download and add signing key for Postgres 15 +RUN mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc -o /etc/apt/keyrings/pgdg.asc \ + && echo "deb [signed-by=/etc/apt/keyrings/pgdg.asc] http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list # Download and add signing key for CRAN repository -# https://www.r-bloggers.com/2022/08/installation-of-r-4-2-on-ubuntu-22-04-1-lts-and-tips-for-spatial-packages/ -# https://phoenixnap.com/kb/install-r-ubuntu -RUN wget -qO- https://cloud.r-project.org/bin/linux/ubuntu/marutter_pubkey.asc | tee -a /etc/apt/trusted.gpg.d/cran_ubuntu_key.asc > /dev/null 2>&1 -RUN add-apt-repository "deb https://cloud.r-project.org/bin/linux/ubuntu $(lsb_release -cs)-cran40/" +RUN curl -fsSL https://cloud.r-project.org/bin/linux/ubuntu/marutter_pubkey.asc -o /etc/apt/keyrings/cran_ubuntu_key.asc \ + && echo "deb [signed-by=/etc/apt/keyrings/cran_ubuntu_key.asc] https://cloud.r-project.org/bin/linux/ubuntu $(lsb_release -cs)-cran40/" > /etc/apt/sources.list.d/cran.list # Adding various application to download packages to install Postgres 15 and CRAN-R RUN apt-get update \ && apt-get -qq install -y texinfo \ - tzdata \ - gettext \ - make \ - openssl \ - #Linux packages needed for installing CRAN-R - dirmngr \ - #Linux packages needed for RlabKey - libcurl4-openssl-dev \ - libssl-dev \ - postgresql-client-15 \ - r-base \ - r-base-dev \ + tzdata \ + gettext \ + make \ + openssl \ + #Linux packages needed for installing CRAN-R + dirmngr \ + #Linux packages needed for RlabKey + libcurl4-openssl-dev \ + libssl-dev \ + postgresql-client-15 \ + r-base \ + r-base-dev \ && apt-get clean # install the necessary R packages diff --git a/docker/gradle/wrapper/gradle-wrapper.jar b/docker/gradle/wrapper/gradle-wrapper.jar index 249e5832f..61285a659 100644 Binary files a/docker/gradle/wrapper/gradle-wrapper.jar and b/docker/gradle/wrapper/gradle-wrapper.jar differ diff --git a/docker/gradle/wrapper/gradle-wrapper.properties b/docker/gradle/wrapper/gradle-wrapper.properties index ae04661ee..2f2958b92 100644 --- a/docker/gradle/wrapper/gradle-wrapper.properties +++ b/docker/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/docker/labkey/Dockerfile b/docker/labkey/Dockerfile index eea97b456..bdf136625 100644 --- a/docker/labkey/Dockerfile +++ b/docker/labkey/Dockerfile @@ -22,7 +22,7 @@ RUN echo -e "Downloading LabKey build from \033[1;33m${Z}\033[0m" \ # ----------------------------------------------------------------------------- # MAIN IMAGE BUILD DEFINITION -FROM --platform=$BUILDPLATFORM wnprcehr/cranrnutils:cranrnutils_${LK_VERSION}${FB_NAME} +FROM wnprcehr/cranrnutils:cranrnutils_${LK_VERSION}${FB_NAME} # creating folders for LabKey installation RUN mkdir -p /labkey/labkey