diff --git a/client/modules/_hooks/src/workspace/index.ts b/client/modules/_hooks/src/workspace/index.ts index 94e373286a..5e4b1b02ab 100644 --- a/client/modules/_hooks/src/workspace/index.ts +++ b/client/modules/_hooks/src/workspace/index.ts @@ -19,3 +19,4 @@ export * from './usePostJobs'; export * from './types'; export * from './useGetAllocations'; export * from './useInteractiveModalContext'; +export * from './useSUAllocations'; diff --git a/client/modules/_hooks/src/workspace/useSUAllocations.ts b/client/modules/_hooks/src/workspace/useSUAllocations.ts new file mode 100644 index 0000000000..b2f07ecb97 --- /dev/null +++ b/client/modules/_hooks/src/workspace/useSUAllocations.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; +import apiClient from '../apiClient'; + +export type TSUAllocation = { + system: string; + host: string; + project_code: string; + awarded: number; + remaining: number; + expiration: string; +}; + +const getSUAllocations = async ({ signal }: { signal: AbortSignal }) => { + const res = await apiClient.get<{ allocations: TSUAllocation[] }>( + '/api/users/allocations/', + { signal } + ); + return res.data.allocations; +}; + +const suAllocationsQuery = () => ({ + queryKey: ['dashboard', 'getSUAllocations'], + queryFn: ({ signal }: { signal: AbortSignal }) => + getSUAllocations({ signal }), + staleTime: 5000, +}); + +export const useGetSUAllocations = () => useQuery(suAllocationsQuery()); diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.module.css b/client/modules/dashboard/src/Dashboard/Dashboard.module.css index 45c2aa47e9..fa2a20ddac 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.module.css +++ b/client/modules/dashboard/src/Dashboard/Dashboard.module.css @@ -5,3 +5,336 @@ * .container { * } */ +/*Dashboard styling*/ + +.dashboardContainer { + display: flex; + gap: 2rem; +} + +.middleSection { + flex: 2; +} + +.sectionHeader { + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + user-select: none; +} + +.section { + margin-bottom: 1.5rem; +} + +.verticalSeparator { + width: 1px; + background-color: #ccc; + margin-top: 2.5rem; + margin-bottom: 2rem; + height: auto; + min-height: 300px; +} + +.rightPanel { + flex: 1.3; + padding-right: 1.5rem; +} + +.statusCard { + background-color: #fff; + padding: 1.5rem; + border-radius: 8px; + border: 1px solid #e0e0e0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + margin-bottom: 1.5rem; +} + +.statusTitle { + margin-bottom: 1rem; +} + +.naText { + color: #999; +} +/*End of Dashboard styling*/ +.sidebar { + background-color: #f5f7fa; + color: #333; + padding: 1.5rem; + border-radius: 8px; + width: 260px; + min-height: calc(100vh - 80px); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.05); +} + +.sidebarTitle { + font-size: 1.55rem; + font-weight: 600; + margin-bottom: 1.2rem; + color: #2c3e50; + border-bottom: 1px solid #ddd; + padding-bottom: 0.4rem; +} + +.sidebarLink { + display: block; + margin: 0.75rem 0; + color: #1f2d3d; + font-size: 1.5rem; + font-weight: 500; + text-decoration: none; + transition: all 0.2s ease; +} + +.sidebarLink:hover { + color: #007bff; + text-decoration: underline; + padding-left: 5px; +} +.sidebar { + position: sticky; + top: 80px; /* after navbar */ +} +.sidebarIcon { + margin-right: 8px; + vertical-align: middle; +} +/*for joblisting*/ + +.jobStatusContainer { + background-color: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 1.5rem; + margin-top: 2rem; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.03); +} + +.jobStatusHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.jobStatusHeader h2 { + font-size: 1.25rem; + margin: 0; +} + +.viewAllLink { + font-size: 0.9rem; + color: #007bff; + text-decoration: none; +} + +.viewAllLink:hover { + text-decoration: underline; +} + +.jobsTableWrapper { + overflow-x: auto; +} +.limitedWidthTable { + max-width: 75%; /* or 800px, adjust as needed */ + min-width: 750px; +} +.recentJobsCard { + max-width: 980px; + background: white; + padding: 16px; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.recentJobsHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.viewAllLink { + font-size: 14px; + color: #0073e6; + text-decoration: none; +} + +.viewAllLink:hover { + text-decoration: underline; +} + +.jobsTableWrapper { + max-width: 100%; +} +.header-details { + display: flex; + gap: 20px; + margin-top: 10px; + font-size: 12px; + color: #666; +} + +.header-details dt { + font-weight: bold; + margin-right: 5px; +} + +.header-details dd { + margin: 0; +} +/* Userguide css */ +.userGuidesWrapper { + margin-top: 1.5rem; + padding: 1.5rem; + background-color: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.userGuidesHeading { + font-size: 16px; + font-weight: 600; + color: #222; + margin-bottom: 1.25rem; + border-bottom: 1px solid #ddd; + padding-bottom: 8px; +} + +.videoGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem 1.2rem; + justify-items: center; +} + +.videoCard, +.videoCardSingle { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.videoCardSingle { + grid-column: span 2; + margin-top: 1rem; +} + +.videoThumbnail { + width: 100%; + max-width: 200px; + border-radius: 6px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.videoThumbnail:hover { + transform: scale(1.03); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.videoTitle { + margin-top: 8px; + font-size: 14px; + color: #0070c9; + font-weight: 500; + text-decoration: none; +} + +.videoTitle:hover { + text-decoration: underline; +} +.youtubeIcon { + width: 14px; + height: 14px; + vertical-align: middle; + margin-right: 6px; + display: inline-block; + transform: translateY(-1px); +} +.headingRow { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; +} + +.userGuidesHeading { + font-size: 16px; + font-weight: 600; + color: #222; + margin: 0; +} + +.moreVideosLink { + font-size: 13px; + font-weight: 500; + color: #0070c9; + text-decoration: none; + transition: color 0.2s ease; +} + +.moreVideosLink:hover { + text-decoration: underline; + color: #004a99; +} +/* jolistingwrapper.css*/ + +.jobActions { + padding-top: 15px; +} +.jobActions :is(a, button) + :is(a, button) { + margin-left: 15px; +} + +.link:hover { + color: var(--global-color-accent--normal); +} +/*su card styling*/ +.suCard { + border: 1px solid #ccc; + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; +} + +.suTitle { + margin-bottom: 12px; +} + +.suUsername { + color: #2b6cb0; +} + +.suSection { + margin-bottom: 12px; +} + +.suHost { + font-weight: bold; + margin-bottom: 4px; +} + +.suTable { + width: 100%; + border-collapse: collapse; +} + +.suTheadRow { + background: #f5f5f5; +} + +.suTh { + padding: 6px 8px; + border-bottom: 1px solid #ddd; + text-align: left; +} + +.suTd { + padding: 6px 8px; + border-bottom: 1px solid #eee; +} diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.spec.tsx b/client/modules/dashboard/src/Dashboard/Dashboard.spec.tsx index 4c63eab3c4..9af252bf57 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.spec.tsx +++ b/client/modules/dashboard/src/Dashboard/Dashboard.spec.tsx @@ -1,10 +1,44 @@ +import React from 'react'; import { render } from '@testing-library/react'; - +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter } from 'react-router-dom'; import Dashboard from './Dashboard'; +import { vi } from 'vitest'; describe('Dashboard', () => { + const queryClient = new QueryClient(); + + // 🛠️ Mock required browser APIs before running any tests + beforeAll(() => { + // Mock matchMedia + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); + + // Mock getComputedStyle + window.getComputedStyle = vi.fn().mockImplementation(() => ({ + getPropertyValue: () => '', // default mock value for any CSS prop + })); + }); + it('should render successfully', () => { - const { baseElement } = render(); + const { baseElement } = render( + + + + + + ); expect(baseElement).toBeTruthy(); }); }); diff --git a/client/modules/dashboard/src/Dashboard/Dashboard.tsx b/client/modules/dashboard/src/Dashboard/Dashboard.tsx index 5a2aad7804..063feb9533 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.tsx +++ b/client/modules/dashboard/src/Dashboard/Dashboard.tsx @@ -1,12 +1,127 @@ +import React, { useState } from 'react'; import styles from './Dashboard.module.css'; +import Quicklinks from './Quicklinks'; +import JobStatus from './Jobstatus'; +import { Table, Tag } from 'antd'; +import { DownOutlined, RightOutlined } from '@ant-design/icons'; +import { useSystemOverview } from '@client/hooks'; +import SUAllocationsCard from './SUAllocationsCard'; +import UserGuides from './UserGuides'; -/* eslint-disable-next-line */ export interface DashboardProps {} +interface HPCSystem { + display_name: string; + hostname: string; + load_percentage: number; + is_operational: boolean; + running: number; + waiting: number; +} export function Dashboard(props: DashboardProps) { + const { data: liveSystems, isLoading } = useSystemOverview(); + const [showJobs, setShowJobs] = useState(false); + const [showAllocations, setShowAllocations] = useState(false); + + const columns = [ + { + title: 'System Name', + dataIndex: 'display_name', + key: 'name', + }, + { + title: 'Status', + dataIndex: 'is_operational', + key: 'status', + render: (isOperational: boolean) => ( + + {isOperational ? 'UP' : 'DOWN'} + + ), + }, + { + title: 'Load', + dataIndex: 'load_percentage', + key: 'load', + render: (load: number, record: HPCSystem) => + record.is_operational ? ( + `${load}%` + ) : ( + (N/A) + ), + }, + { + title: 'Running Jobs', + dataIndex: 'running', + key: 'running', + render: (value: number, record: HPCSystem) => + record.is_operational ? ( + value + ) : ( + (N/A) + ), + }, + { + title: 'Waiting Jobs', + dataIndex: 'waiting', + key: 'waiting', + render: (value: number, record: HPCSystem) => + record.is_operational ? ( + value + ) : ( + (N/A) + ), + }, + ]; + return ( -
-

Welcome to Dashboard!

+
+ + +
+

DASHBOARD

+ + {/* Recent Jobs */} +
+

setShowJobs(!showJobs)} + > + {showJobs ? : } Recent Jobs +

+ {showJobs && } +
+ + {/* Allocations */} +
+

setShowAllocations(!showAllocations)} + > + {showAllocations ? : } Allocations +

+ {showAllocations && } +
+
+ +
+ +
+
+

System Status

+ ({ + key: sys.hostname, + ...sys, + }))} + loading={isLoading} + size="small" + pagination={false} + /> + + + ); } diff --git a/client/modules/dashboard/src/Dashboard/JobDetailModalWrapper.tsx b/client/modules/dashboard/src/Dashboard/JobDetailModalWrapper.tsx new file mode 100644 index 0000000000..d30c35823a --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/JobDetailModalWrapper.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { Modal, Button, Layout } from 'antd'; +import { useGetApps, useGetJobs, TAppResponse, TTapisJob } from '@client/hooks'; +import { Spinner } from '@client/common-components'; +import { JobsDetailModalBody } from '@client/workspace'; +import styles from './Dashboard.module.css'; +interface JobDetailModalWrapperProps { + uuid: string | null; + isOpen: boolean; + onClose: () => void; +} + +export const JobDetailModalWrapper: React.FC = ({ + uuid, + isOpen, + onClose, +}) => { + const { data: jobData, isLoading } = useGetJobs('select', { + uuid: uuid || '', + }) as { + data: TTapisJob; + isLoading: boolean; + }; + + const appId = jobData?.appId; + const appVersion = jobData?.appVersion; + + const { data: appData, isLoading: isAppLoading } = useGetApps({ + appId, + appVersion, + }) as { + data: TAppResponse; + isLoading: boolean; + }; + + if (!uuid) return null; + + return ( + + Job Detail: {jobData?.name} + {jobData && ( +
+
Job UUID:
+
{jobData.uuid}
+
Application:
+
{JSON.parse(jobData.notes).label || jobData.appId}
+
System:
+
{jobData.execSystemId}
+
+ )} + + } + width="60%" + open={isOpen} + onCancel={onClose} + footer={[ + , + ]} + > + {isLoading || isAppLoading ? ( + + + + ) : ( + jobData && + )} +
+ ); +}; diff --git a/client/modules/dashboard/src/Dashboard/JobsListingWrapper.tsx b/client/modules/dashboard/src/Dashboard/JobsListingWrapper.tsx new file mode 100644 index 0000000000..45059ae4b4 --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/JobsListingWrapper.tsx @@ -0,0 +1,110 @@ +import React, { useMemo } from 'react'; +import { Row, Flex, Button } from 'antd'; +import { JobsListingTable, TJobsListingColumns } from '@client/workspace'; +import { getStatusText, truncateMiddle } from '@client/workspace'; +import { formatDateTimeFromValue } from '@client/workspace'; +import styles from './Dashboard.module.css'; +import type { TTapisJob } from '@client/hooks'; +import { JobActionButton } from '@client/workspace'; +import { isTerminalState } from '@client/workspace'; + +interface JobsListingWrapperProps { + onViewDetails?: (uuid: string) => void; +} + +export const JobsListingWrapper: React.FC = ({ + onViewDetails, +}) => { + const columns: TJobsListingColumns = useMemo( + () => [ + { + title: 'Job Name', + dataIndex: 'name' as keyof TTapisJob, + ellipsis: true, + width: '30%', + render: (_: unknown, job: TTapisJob) => ( + + {truncateMiddle(job.name, 35)} + + {isTerminalState(job.status) && ( + + )} + {onViewDetails ? ( + + ) : ( + + View Details + + )} + + + ), + }, + { + title: 'Application', + dataIndex: 'appId' as keyof TTapisJob, + width: '10%', + render: (appId: string, job: TTapisJob) => { + const appNotes = JSON.parse(job.notes); + return ( + appNotes.label || appId.charAt(0).toUpperCase() + appId.slice(1) + ); + }, + }, + { + title: 'Job Status', + dataIndex: 'status' as keyof TTapisJob, + width: '10%', + render: (status: string) => <>{getStatusText(status)}, + }, + { + title: 'Time Submitted - Finished', + dataIndex: 'created' as keyof TTapisJob, + width: '30%', + render: (_: unknown, job: TTapisJob) => { + const start = formatDateTimeFromValue(job.created); + const end = formatDateTimeFromValue(job.ended); + return ( +
+ {start} - {end} +
+ ); + }, + }, + ], + [onViewDetails] + ); + + const filterFn = (listing: TTapisJob[]) => { + // Sort by created date (most recent first), then take top 4 + return [...listing] + .sort( + (a, b) => new Date(b.created).getTime() - new Date(a.created).getTime() + ) + .slice(0, 4); + }; + + return ( + + ); +}; diff --git a/client/modules/dashboard/src/Dashboard/Jobstatus.tsx b/client/modules/dashboard/src/Dashboard/Jobstatus.tsx new file mode 100644 index 0000000000..dc0f7d29d6 --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/Jobstatus.tsx @@ -0,0 +1,41 @@ +import React, { useState } from 'react'; +import styles from './Dashboard.module.css'; +import { JobsListingWrapper } from './JobsListingWrapper'; +import { JobDetailModalWrapper } from './JobDetailModalWrapper'; + +const JobStatus: React.FC = () => { + const [selectedJobUuid, setSelectedJobUuid] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleViewDetails = (uuid: string) => { + setSelectedJobUuid(uuid); + setIsModalOpen(true); + }; + + const handleCloseModal = () => { + setIsModalOpen(false); + setSelectedJobUuid(null); + }; + + return ( +
+
+

Recent Jobs

+ + View All Jobs + +
+
+ +
+ + +
+ ); +}; + +export default JobStatus; diff --git a/client/modules/dashboard/src/Dashboard/Quicklinks.tsx b/client/modules/dashboard/src/Dashboard/Quicklinks.tsx new file mode 100644 index 0000000000..47411e450c --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/Quicklinks.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import styles from './Dashboard.module.css'; + +const Quicklinks = () => { + return ( + + ); +}; + +export default Quicklinks; diff --git a/client/modules/dashboard/src/Dashboard/SUAllocationsCard.tsx b/client/modules/dashboard/src/Dashboard/SUAllocationsCard.tsx new file mode 100644 index 0000000000..b86f68725b --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/SUAllocationsCard.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { useGetSUAllocations, useAuthenticatedUser } from '@client/hooks'; +import styles from './Dashboard.module.css'; +const HOST_LABELS: Record = { + 'ls6.tacc.utexas.edu': 'Lonestar6 (HPC)', + 'frontera.tacc.utexas.edu': 'Frontera (HPC)', + 'stampede3.tacc.utexas.edu': 'Stampede3 (HPC)', + 'vista.tacc.utexas.edu': 'Vista (AI/GPU)', + 'data.tacc.utexas.edu': 'Corral (Storage)', +}; + +const SUAllocationsCard = () => { + const { user } = useAuthenticatedUser(); + const { data, isLoading, error } = useGetSUAllocations(); + + if (isLoading) return
Loading SU allocations...
; + if (error) return
Error loading SU allocations
; + if (!data || data.length === 0) return
No allocation data found.
; + + const validData = data.filter((alloc) => Number(alloc.awarded) > 0); + + const grouped = validData.reduce>>( + (acc, alloc) => { + const description = HOST_LABELS[alloc.host] || '—'; + if (!acc[description]) acc[description] = {}; + if (!acc[description][alloc.host]) acc[description][alloc.host] = []; + acc[description][alloc.host].push(alloc); + return acc; + }, + {} + ); + + return ( +
+

+ Allocations of{' '} + {user?.username || 'User'} +

+ {Object.entries(grouped).map(([description, hosts]) => ( +
+

{description}

+ {Object.entries(hosts).map(([host, allocations]) => ( +
+
Host: {host}
+
+ + + + + + + + + + {allocations.map((alloc, i) => ( + + + + + + + ))} + +
Project CodeAwardedRemainingExpiration
{alloc.project_code}{alloc.awarded}{alloc.remaining}{alloc.expiration}
+
+ ))} +
+ ))} +
+ ); +}; + +export default SUAllocationsCard; diff --git a/client/modules/dashboard/src/Dashboard/UserGuides.tsx b/client/modules/dashboard/src/Dashboard/UserGuides.tsx new file mode 100644 index 0000000000..1c365db634 --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/UserGuides.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import styles from './Dashboard.module.css'; + +const videos = [ + { + id: 'w0lhfz03QIk', + title: 'Checking Allocation Balance', + }, + { + id: '_wDIKMwqej8', + title: 'Adding users to allocation', + }, + { + id: 'X4mb6PJ9GD0', + title: 'Opening a help ticket', + }, +]; + +const UserGuides = () => { + return ( +
+
+

User Guides & Tutorials

+ + More Videos → + +
+ +
+ {videos.slice(0, 2).map((video) => ( +
+ + {video.title} + + + YouTube + {video.title} + +
+ ))} +
+ +
+ + {videos[2].title} + + + YouTube + {videos[2].title} + +
+
+ ); +}; + +export default UserGuides; diff --git a/client/modules/dashboard/src/index.ts b/client/modules/dashboard/src/index.ts index 8da971af85..63298d4a27 100644 --- a/client/modules/dashboard/src/index.ts +++ b/client/modules/dashboard/src/index.ts @@ -1 +1,4 @@ export * from './Dashboard/Dashboard'; +export * from './Dashboard/JobsListingWrapper'; +export * from './Dashboard/JobDetailModalWrapper'; +export * from './Dashboard/Jobstatus'; diff --git a/client/modules/workspace/src/Toast/index.tsx b/client/modules/workspace/src/Toast/index.ts similarity index 100% rename from client/modules/workspace/src/Toast/index.tsx rename to client/modules/workspace/src/Toast/index.ts diff --git a/client/modules/workspace/src/index.ts b/client/modules/workspace/src/index.ts index fb6f0a6939..0eb693725b 100644 --- a/client/modules/workspace/src/index.ts +++ b/client/modules/workspace/src/index.ts @@ -12,3 +12,4 @@ export * from './utils'; export * from './constants'; export * from './InteractiveSessionModal'; export * from './components/SystemStatusModal/SystemStatusModal'; +export * from './JobsListing/JobsListingTable/JobsListingTable'; diff --git a/client/src/main.tsx b/client/src/main.tsx index bb8e6b55e3..9e696d9e3a 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -9,6 +9,8 @@ import onboardingRouter from './onboarding/onboardingRouter'; import { Dashboard } from '@client/dashboard'; import { ConfigProvider, ThemeConfig } from 'antd'; +/**removed unrequired imports */ + console.log(Dashboard); const queryClient = new QueryClient(); diff --git a/designsafe/apps/api/users/urls.py b/designsafe/apps/api/users/urls.py index 6cd6bccaae..899caa57d2 100644 --- a/designsafe/apps/api/users/urls.py +++ b/designsafe/apps/api/users/urls.py @@ -1,10 +1,11 @@ from django.urls import path, re_path as url -from designsafe.apps.api.users.views import SearchView, AuthenticatedView, UsageView, ProjectUserView, PublicView +from designsafe.apps.api.users.views import SearchView, AuthenticatedView, UsageView, ProjectUserView, PublicView, SUAllocationsView urlpatterns = [ path("project-lookup/", ProjectUserView.as_view()), url(r'^$', SearchView.as_view(), name='user_search'), url(r'^auth/$', AuthenticatedView.as_view(), name='user_authenticated'), url(r'^usage/$', UsageView.as_view(), name='user_usage'), - url(r'^public/$', PublicView.as_view(), name='user_public') + url(r'^public/$', PublicView.as_view(), name='user_public'), + path('allocations/', SUAllocationsView.as_view(), name='su_allocations'), ] diff --git a/designsafe/apps/api/users/utils.py b/designsafe/apps/api/users/utils.py index 3d93baa099..793da15f59 100644 --- a/designsafe/apps/api/users/utils.py +++ b/designsafe/apps/api/users/utils.py @@ -9,9 +9,46 @@ from django.contrib.auth import get_user_model from designsafe.apps.workspace.models.allocations import UserAllocations - logger = logging.getLogger(__name__) +def _get_tas_allocations(username): + tas_client = TASClient( + baseURL=settings.TAS_URL, + credentials={ + "username": settings.TAS_CLIENT_KEY, + "password": settings.TAS_CLIENT_SECRET, + }, + ) + tas_projects = tas_client.projects_for_user(username) + + with open("designsafe/apps/api/users/tas_to_tacc_resources.json", encoding="utf-8") as file: + tas_to_tacc_resources = json.load(file) + + allocation_table = [] + + for proj in tas_projects: + charge_code = proj.get("chargeCode", "N/A") + for alloc in proj.get("allocations", []): + resource_name = alloc.get("resource", "UNKNOWN") + status = alloc.get("status", "UNKNOWN") + + # Proceed anyway regardless of status or missing mapping + resource_info = tas_to_tacc_resources.get(resource_name, {"host": "unknown"}) + + awarded = alloc.get("computeAllocated", 0) + used = alloc.get("computeUsed", 0.0) + remaining = round(awarded - used, 3) + + allocation_table.append({ + "system": resource_name, + "host": resource_info["host"], + "project_code": charge_code, + "awarded": awarded, + "remaining": remaining, + "expiration": alloc.get("end", "N/A")[:10] + }) + + return {"detailed_allocations": allocation_table} def get_user_data(username): """Returns user contact information @@ -23,7 +60,6 @@ def get_user_data(username): user_data = tas_client.get_user(username=username) return user_data - def list_to_model_queries(q_comps): query = None if len(q_comps) > 2: @@ -36,7 +72,6 @@ def list_to_model_queries(q_comps): query |= Q(last_name__icontains=q_comps[1]) return query - def q_to_model_queries(q): if not q: return None @@ -54,51 +89,6 @@ def q_to_model_queries(q): return query -def _get_tas_allocations(username): - """Returns user allocations on TACC resources - - : returns: allocations - : rtype: dict - """ - - tas_client = TASClient( - baseURL=settings.TAS_URL, - credentials={ - "username": settings.TAS_CLIENT_KEY, - "password": settings.TAS_CLIENT_SECRET, - }, - ) - tas_projects = tas_client.projects_for_user(username) - - with open( - "designsafe/apps/api/users/tas_to_tacc_resources.json", encoding="utf-8" - ) as file: - tas_to_tacc_resources = json.load(file) - - hosts = {} - - for tas_proj in tas_projects: - # Each project from tas has an array of length 1 for its allocations - alloc = tas_proj["allocations"][0] - charge_code = tas_proj["chargeCode"] - if alloc["resource"] in tas_to_tacc_resources: - resource = dict(tas_to_tacc_resources[alloc["resource"]]) - resource["allocation"] = dict(alloc) - - # Separate active and inactive allocations and make single entry for each project - if resource["allocation"]["status"] == "Active": - if ( - resource["host"] in hosts - and charge_code not in hosts[resource["host"]] - ): - hosts[resource["host"]].append(charge_code) - elif resource["host"] not in hosts: - hosts[resource["host"]] = [charge_code] - return { - "hosts": hosts, - } - - def _get_latest_allocations(username): """ Creates or updates allocations cache for a given user and returns new allocations diff --git a/designsafe/apps/api/users/views.py b/designsafe/apps/api/users/views.py index 39a0a646f5..5713a2db57 100644 --- a/designsafe/apps/api/users/views.py +++ b/designsafe/apps/api/users/views.py @@ -192,3 +192,19 @@ def get(self, request): res_dict = {"userData": res_list} return JsonResponse(res_dict) + + +class SUAllocationsView(SecureMixin, View): + """API View for fetching SU allocations for the authenticated user""" + + def get(self, request): + if not request.user.is_authenticated: + return JsonResponse({"message": "Unauthorized"}, status=401) + + try: + username = request.user.username + allocations = users_utils.get_allocations(request.user, force=True) + return JsonResponse({"allocations": allocations.get("detailed_allocations", [])}) + except Exception as e: + logger.exception(f"Error fetching SU allocations: {str(e)}") + return JsonResponse({"error": "Failed to fetch SU allocations."}, status=500) diff --git a/designsafe/apps/workspace/api/views.py b/designsafe/apps/workspace/api/views.py index 67b60b5685..3d45ebe262 100644 --- a/designsafe/apps/workspace/api/views.py +++ b/designsafe/apps/workspace/api/views.py @@ -2,7 +2,6 @@ .. :module:: apps.workspace.api.views :synopsys: Views to handle Workspace API """ - import logging import json from urllib.parse import urlparse @@ -29,7 +28,6 @@ from designsafe.apps.workspace.api.utils import check_job_for_timeout from designsafe.apps.onboarding.steps.system_access_v3 import create_system_credentials - logger = logging.getLogger(__name__) METRICS = logging.getLogger(f"metrics.{__name__}") @@ -56,7 +54,6 @@ }, } - def _app_license_type(app_def): """Gets an app's license type, if any.""" @@ -64,7 +61,6 @@ def _app_license_type(app_def): lic_type = app_lic_type if app_lic_type in LICENSE_TYPES else None return lic_type - def _get_user_app_license(license_type, user): """Gets a user's app license from the database.""" @@ -77,7 +73,6 @@ def _get_user_app_license(license_type, user): lic = license_model.objects.filter(user=user).first() return lic - def _get_systems( user: object, can_exec: bool, systems: list = None, list_type: str = "ALL" ) -> list: @@ -93,7 +88,6 @@ def _get_systems( listType=list_type, select="allAttributes", search=search_string ) - def _get_app(app_id, app_version, user): """Gets an app from Tapis, and includes license and execution system info in response.""" @@ -113,7 +107,6 @@ def _get_app(app_id, app_version, user): return data - def test_system_access_ok( tapis: object, username: str, system_id: str, path: str = "/" ) -> bool: @@ -131,7 +124,6 @@ def test_system_access_ok( ) raise - def test_system_needs_keys( tapis: object, username: str, system_id: str, path: str = "/" ) -> bool: @@ -177,7 +169,6 @@ def test_system_needs_keys( f"User {username} does not have system credentials and cannot push keys or create credentials for system {system_id}." ) from exc - class SystemListingView(AuthenticatedApiView): """System Listing View""" @@ -215,7 +206,6 @@ def get(self, request, system_id): {"status": 200, "response": system_def}, encoder=BaseTapisResultSerializer ) - class AppsView(AuthenticatedApiView): """View for Tapis app listings.""" @@ -262,7 +252,6 @@ def get(self, request, *args, **kwargs): encoder=BaseTapisResultSerializer, ) - class AppsTrayView(AuthenticatedApiView): """Views for Workspace Apps Tray listings.""" @@ -451,6 +440,7 @@ def _get_public_apps(self, user, verbose): categories.append(category_result) return categories, html_definitions + def get(self, request, *args, **kwargs): """ @@ -523,7 +513,6 @@ def get(self, request, *args, **kwargs): encoder=BaseTapisResultSerializer, ) - class AppDescriptionView(AuthenticatedApiView): """Views for retreiving AppDescription objects.""" @@ -536,7 +525,6 @@ def get(self, request, *args, **kwargs): return JsonResponse({"message": f"No description found for {app_id}"}) return JsonResponse({"response": data}) - class JobHistoryView(AuthenticatedApiView): """View for returning job history""" @@ -893,13 +881,10 @@ def post(self, request, *args, **kwargs): encoder=BaseTapisResultSerializer, ) - class AllocationsView(AuthenticatedApiView): """Allocations API View""" - def get(self, request): """Returns active user allocations on TACC resources - : returns: {'response': {'active': allocations, 'portal_alloc': settings.PORTAL_ALLOCATION, 'inactive': inactive, 'hosts': hosts}} : rtype: dict """ @@ -911,7 +896,6 @@ def get(self, request): for allocation in allocations if allocation not in settings.ALLOCATIONS_TO_EXCLUDE ] - return JsonResponse( { "status": 200,