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',