diff --git a/api/PclusterApiHandler.py b/api/PclusterApiHandler.py
index 9ce9d688..cc12fe58 100644
--- a/api/PclusterApiHandler.py
+++ b/api/PclusterApiHandler.py
@@ -50,6 +50,7 @@
USER_ROLES_CLAIM = os.getenv("USER_ROLES_CLAIM", "cognito:groups")
SSM_LOG_GROUP_NAME = os.getenv("SSM_LOG_GROUP_NAME")
ARG_VERSION="version"
+ANALYSIS_RESULTS_BASE_PATH = "/fsx/analysis_results/ubuntu"
try:
if (not USER_POOL_ID or USER_POOL_ID == "") and SECRET_ID:
@@ -435,6 +436,54 @@ def queue_status():
return {"jobs": []} if jobs == "" else {"jobs": json.loads(jobs)}
+def analysis_worksets():
+ user = request.args.get("user", "ec2-user")
+ instance_id = request.args.get("instance_id")
+ region = request.args.get("region")
+
+ command = (
+ "python3 - <<\\\"PY\\\"\n"
+ "import json\n"
+ "import os\n"
+ f"base = {ANALYSIS_RESULTS_BASE_PATH!r}\n"
+ "results = []\n"
+ "if os.path.isdir(base):\n"
+ " for name in sorted(os.listdir(base)):\n"
+ " path = os.path.join(base, name)\n"
+ " if not os.path.isdir(path):\n"
+ " continue\n"
+ " units_path = os.path.join(path, \"daylily-omics-analysis\", \"config\", \"units.tsv\")\n"
+ " count = 0\n"
+ " try:\n"
+ " with open(units_path, \"r\", encoding=\"utf-8\") as units_file:\n"
+ " lines = [line for line in units_file if line.strip()]\n"
+ " count = max(len(lines) - 1, 0) if lines else 0\n"
+ " except FileNotFoundError:\n"
+ " count = 0\n"
+ " except Exception:\n"
+ " count = 0\n"
+ " results.append({\"name\": name, \"unitsCount\": count})\n"
+ "print(json.dumps(results))\n"
+ "PY"
+ )
+
+ worksets = ssm_command(region, instance_id, user, command)
+ if isinstance(worksets, tuple):
+ return worksets
+
+ output = worksets.strip()
+ if not output:
+ return {"worksets": []}
+
+ try:
+ parsed = json.loads(output)
+ except json.JSONDecodeError:
+ logger.error("Unable to parse analysis worksets output: %s", output)
+ return {"message": "Unable to retrieve analysis worksets."}, 500
+
+ return {"worksets": parsed}
+
+
def cancel_job():
user = request.args.get("user", "ec2-user")
instance_id = request.args.get("instance_id")
diff --git a/api/validation/schemas.py b/api/validation/schemas.py
index 41152edf..49b91070 100644
--- a/api/validation/schemas.py
+++ b/api/validation/schemas.py
@@ -68,6 +68,15 @@ class QueueStatusSchema(Schema):
QueueStatus = QueueStatusSchema(unknown=INCLUDE)
+class AnalysisWorksetsSchema(Schema):
+ user = fields.String(validate=validate.Length(max=64))
+ instance_id = fields.String(required=True, validate=validate.Length(max=60))
+ region = fields.String(required=True, validate=aws_region_validator)
+
+
+AnalysisWorksets = AnalysisWorksetsSchema(unknown=INCLUDE)
+
+
class ScontrolJobSchema(Schema):
user = fields.String(validate=validate.Length(max=64))
instance_id = fields.String(required=True, validate=validate.Length(max=60))
diff --git a/app.py b/app.py
index 3c075ccb..872315ec 100644
--- a/app.py
+++ b/app.py
@@ -18,6 +18,7 @@
from api.PclusterApiHandler import (
authenticated,
cancel_job,
+ analysis_worksets,
create_user,
delete_user,
ec2_action,
@@ -45,8 +46,23 @@
from api.security.csrf.csrf import csrf_needed
from api.security.fingerprint import CognitoFingerprintGenerator
from api.validation import validated, EC2Action
-from api.validation.schemas import CreateUser, DeleteUser, GetClusterConfig, GetCustomImageConfig, GetAwsConfig, GetInstanceTypes,\
- Login, PushLog, PriceEstimate, GetDcvSession, QueueStatus, ScontrolJob, CancelJob, Sacct
+ from api.validation.schemas import (
+ CreateUser,
+ DeleteUser,
+ GetClusterConfig,
+ GetCustomImageConfig,
+ GetAwsConfig,
+ GetInstanceTypes,
+ Login,
+ PushLog,
+ PriceEstimate,
+ GetDcvSession,
+ QueueStatus,
+ ScontrolJob,
+ CancelJob,
+ Sacct,
+ AnalysisWorksets,
+ )
ADMINS_GROUP = { "admin" }
@@ -164,6 +180,12 @@ def delete_user_():
def queue_status_():
return queue_status()
+ @app.route("/manager/analysis_worksets")
+ @authenticated(ADMINS_GROUP)
+ @validated(params=AnalysisWorksets)
+ def analysis_worksets_():
+ return analysis_worksets()
+
@app.route("/manager/cancel_job")
@authenticated(ADMINS_GROUP)
@validated(params=CancelJob)
diff --git a/frontend/locales/en/strings.json b/frontend/locales/en/strings.json
index f9a1250f..ff0a95ec 100644
--- a/frontend/locales/en/strings.json
+++ b/frontend/locales/en/strings.json
@@ -106,11 +106,42 @@
"details": "Details",
"instances": "Instances",
"storage": "Storage",
+ "analysisWorksets": "Analysis worksets",
"scheduling": "Job status",
"accounting": "Accounting",
"stackEvents": "Stack events",
"costMonitoring": "Cost monitoring"
},
+ "analysisWorksets": {
+ "title": "Analysis worksets",
+ "loadingText": "Loading analysis worksets...",
+ "columns": {
+ "name": "Workset",
+ "units": "Unit count"
+ },
+ "actions": {
+ "refresh": "Refresh"
+ },
+ "filtering": {
+ "placeholder": "Find worksets",
+ "ariaLabel": "Find analysis worksets",
+ "countText": "Results: {{count}}",
+ "empty": {
+ "title": "No analysis worksets",
+ "subtitle": "No analysis worksets to display."
+ },
+ "noMatch": {
+ "title": "No matches",
+ "subtitle": "No analysis worksets match the filters.",
+ "clearFilter": "Clear filter"
+ }
+ },
+ "empty": {
+ "title": "No analysis worksets",
+ "subtitle": "No analysis worksets were found in the analysis results directory.",
+ "noHeadNodeSubtitle": "The head node must be running before analysis worksets can be retrieved."
+ }
+ },
"properties": {
"title": "Properties",
"sshcommand": {
diff --git a/frontend/src/model.tsx b/frontend/src/model.tsx
index 1f7a2521..db47506d 100644
--- a/frontend/src/model.tsx
+++ b/frontend/src/model.tsx
@@ -811,6 +811,39 @@ function QueueStatus(
})
}
+async function GetAnalysisWorksets(
+ clusterName: string,
+ instanceId: string,
+ user?: string,
+) {
+ const region =
+ getState(['app', 'selectedRegion']) || getState(['aws', 'region'])
+ let url = `manager/analysis_worksets?instance_id=${instanceId}®ion=${region}`
+ if (user) {
+ url += `&user=${encodeURIComponent(user)}`
+ }
+
+ try {
+ const response = await request('get', url)
+ if (response.status === 200) {
+ setState(
+ ['clusters', 'index', clusterName, 'analysisWorksets'],
+ response.data.worksets || [],
+ )
+ }
+ } catch (error) {
+ const axiosError = error as AxiosError
+ if (axiosError.response) {
+ console.log(axiosError.response)
+ const message =
+ (axiosError.response.data as {message?: string})?.message ||
+ axiosError.message
+ notify(`Error: ${message}`, 'error')
+ }
+ console.log(error)
+ }
+}
+
function CancelJob(
instanceId: any,
user: any,
@@ -986,6 +1019,7 @@ export {
LoadAwsConfig,
GetDcvSession,
QueueStatus,
+ GetAnalysisWorksets,
CancelJob,
SlurmAccounting,
JobInfo,
diff --git a/frontend/src/old-pages/Clusters/AnalysisWorksets.tsx b/frontend/src/old-pages/Clusters/AnalysisWorksets.tsx
new file mode 100644
index 00000000..1ab0ec44
--- /dev/null
+++ b/frontend/src/old-pages/Clusters/AnalysisWorksets.tsx
@@ -0,0 +1,202 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance
+// with the License. A copy of the License is located at
+//
+// http://aws.amazon.com/apache2.0/
+//
+// or in the "LICENSE.txt" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES
+// OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react'
+import {useTranslation} from 'react-i18next'
+
+import {GetAnalysisWorksets} from '../../model'
+import {useState, getState} from '../../store'
+import {clusterDefaultUser} from '../../util'
+
+import {
+ Button,
+ Header,
+ SpaceBetween,
+ Table,
+ TextFilter,
+} from '@cloudscape-design/components'
+import {useCollection} from '@cloudscape-design/collection-hooks'
+
+import EmptyState from '../../components/EmptyState'
+import {extendCollectionsOptions} from '../../shared/extendCollectionsOptions'
+import {EC2Instance} from '../../types/clusters'
+
+interface AnalysisWorkset {
+ name: string
+ unitsCount: number
+}
+
+export default function AnalysisWorksets() {
+ const {t} = useTranslation()
+ const clusterName = useState(['app', 'clusters', 'selected']) as string | null
+ const cluster = useState(['clusters', 'index', clusterName])
+ const headNode: EC2Instance | undefined = useState([
+ 'clusters',
+ 'index',
+ clusterName,
+ 'headNode',
+ ])
+ const worksets: AnalysisWorkset[] =
+ (useState([
+ 'clusters',
+ 'index',
+ clusterName,
+ 'analysisWorksets',
+ ]) as AnalysisWorkset[] | undefined) || []
+ const [loading, setLoading] = React.useState(false)
+
+ const refresh = React.useCallback(async () => {
+ const selectedCluster = getState(['app', 'clusters', 'selected'])
+ const currentHeadNode: EC2Instance | undefined = selectedCluster
+ ? getState(['clusters', 'index', selectedCluster, 'headNode'])
+ : undefined
+
+ if (!selectedCluster || !currentHeadNode?.instanceId) {
+ return
+ }
+
+ setLoading(true)
+ try {
+ await GetAnalysisWorksets(
+ selectedCluster,
+ currentHeadNode.instanceId,
+ cluster && clusterDefaultUser(cluster),
+ )
+ } finally {
+ setLoading(false)
+ }
+ }, [cluster])
+
+ React.useEffect(() => {
+ if (headNode?.instanceId && clusterName) {
+ refresh()
+ }
+ }, [clusterName, headNode?.instanceId, refresh])
+
+ const {
+ items,
+ actions,
+ collectionProps,
+ filterProps,
+ filteredItemsCount,
+ } = useCollection(
+ worksets,
+ extendCollectionsOptions({
+ filtering: {
+ empty: (
+
+ ),
+ noMatch: (
+ actions.setFiltering('')}>
+ {t('cluster.analysisWorksets.filtering.noMatch.clearFilter')}
+
+ }
+ />
+ ),
+ },
+ sorting: {
+ defaultState: {
+ sortingColumn: {
+ sortingField: 'name',
+ },
+ },
+ },
+ }),
+ )
+
+ const hasHeadNode = !!headNode?.instanceId
+
+ return (
+
+
+
+ }
+ >
+ {t('cluster.analysisWorksets.title')}
+
+ }
+ columnDefinitions={[
+ {
+ id: 'name',
+ header: t('cluster.analysisWorksets.columns.name'),
+ cell: item => item.name,
+ },
+ {
+ id: 'units',
+ header: t('cluster.analysisWorksets.columns.units'),
+ cell: item => item.unitsCount,
+ },
+ ]}
+ items={items}
+ trackBy="name"
+ filter={
+
+ }
+ empty={
+
+ {t('cluster.analysisWorksets.actions.refresh')}
+
+ ) : undefined
+ }
+ />
+ }
+ pagination={undefined}
+ wrapLines
+ stickyHeader
+ resizableColumns
+ stripedRows
+ selectionType={undefined}
+ />
+ )
+}
diff --git a/frontend/src/old-pages/Clusters/Details.tsx b/frontend/src/old-pages/Clusters/Details.tsx
index 5e28663f..e8d5707e 100644
--- a/frontend/src/old-pages/Clusters/Details.tsx
+++ b/frontend/src/old-pages/Clusters/Details.tsx
@@ -21,6 +21,7 @@ import Accounting from './Accounting'
import StackEvents from './StackEvents'
import Instances from './Instances'
import Filesystems from './Filesystems'
+import AnalysisWorksets from './AnalysisWorksets'
import Scheduling from './Scheduling'
import Properties from './Properties'
import Loading from '../../components/Loading'
@@ -76,6 +77,11 @@ export default function ClusterTabs() {
id: 'storage',
content: ,
},
+ {
+ label: t('cluster.tabs.analysisWorksets'),
+ id: 'analysis-worksets',
+ content: ,
+ },
{
label: t('cluster.tabs.scheduling'),
id: 'scheduling',