diff --git a/client/modules/_hooks/src/favouritesApi.ts b/client/modules/_hooks/src/favouritesApi.ts new file mode 100644 index 0000000000..7c3196714a --- /dev/null +++ b/client/modules/_hooks/src/favouritesApi.ts @@ -0,0 +1,104 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from './apiClient'; + +export interface FavoriteTool { + tool_id: string; + version?: string; +} + +const fetchFavorites = async ({ + signal, +}: { + signal: AbortSignal; +}): Promise => { + const res = await apiClient.get('/api/workspace/user-favorites/', { signal }); + return res.data; +}; + +const addFavorite = async (toolId: string): Promise => { + await apiClient.post('/api/workspace/user-favorites/', { tool_id: toolId }); +}; + +const removeFavorite = async (toolId: string): Promise => { + await apiClient.post('/api/workspace/user-favorites/remove/', { + tool_id: toolId, + }); +}; + +export const useFavorites = () => { + return useQuery({ + queryKey: ['workspace', 'favorites'], + queryFn: fetchFavorites, + staleTime: 5 * 60 * 1000, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); +}; + +export const useAddFavorite = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: addFavorite, + onMutate: async (toolId) => { + await queryClient.cancelQueries({ queryKey: ['workspace', 'favorites'] }); + + const previousFavorites = queryClient.getQueryData([ + 'workspace', + 'favorites', + ]); + + queryClient.setQueryData( + ['workspace', 'favorites'], + (old = []) => [...old, { tool_id: toolId }] + ); + + return { previousFavorites }; + }, + onError: (_err, _toolId, context) => { + if (context?.previousFavorites) { + queryClient.setQueryData( + ['workspace', 'favorites'], + context.previousFavorites + ); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace', 'favorites'] }); + }, + }); +}; + +export const useRemoveFavorite = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: removeFavorite, + onMutate: async (toolId) => { + await queryClient.cancelQueries({ queryKey: ['workspace', 'favorites'] }); + + const previousFavorites = queryClient.getQueryData([ + 'workspace', + 'favorites', + ]); + + queryClient.setQueryData( + ['workspace', 'favorites'], + (old = []) => old.filter((fav) => fav.tool_id !== toolId) + ); + + return { previousFavorites }; + }, + onError: (_err, _toolId, context) => { + if (context?.previousFavorites) { + queryClient.setQueryData( + ['workspace', 'favorites'], + context.previousFavorites + ); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workspace', 'favorites'] }); + }, + }); +}; diff --git a/client/modules/_hooks/src/index.ts b/client/modules/_hooks/src/index.ts index 701dc2d83d..ee429505ca 100644 --- a/client/modules/_hooks/src/index.ts +++ b/client/modules/_hooks/src/index.ts @@ -8,3 +8,4 @@ export * from './datafiles'; export * from './systems'; export * from './notifications'; export * from './onboarding'; +export * from './favouritesApi'; 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..e174237fce 100644 --- a/client/modules/dashboard/src/Dashboard/Dashboard.tsx +++ b/client/modules/dashboard/src/Dashboard/Dashboard.tsx @@ -1,13 +1,186 @@ +import { useState } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Table, Tag } from 'antd'; +import { DownOutlined, RightOutlined } from '@ant-design/icons'; + +import Quicklinks from './QuickLinksNavbar'; +import RecentlyAccessed from './RecentlyAccessed'; +import RecentProjects from './RecentProjects'; +import { TicketList } from './TicketList'; +import JobStatus from './Jobstatus'; +import { useSystemOverview } from '@client/hooks'; +import SUAllocationsCard from './SUAllocationsCard'; +import UserGuides from './UserGuides'; + import styles from './Dashboard.module.css'; -/* eslint-disable-next-line */ -export interface DashboardProps {} +const queryClient = new QueryClient(); + +interface HPCSystem { + display_name: string; + hostname: string; + load_percentage: number; + is_operational: boolean; + running: number; + waiting: number; +} + +export function Dashboard() { + const { data: liveSystems, isLoading } = useSystemOverview(); + const [showJobs, setShowJobs] = useState(false); + const [showProjects, setShowProjects] = useState(false); + const [showTickets, setShowTickets] = 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) + ), + }, + ]; -export function Dashboard(props: DashboardProps) { return ( -
-

Welcome to Dashboard!

-
+ +
+ {/* Sidebar */} + + + + {/* Middle Section */} +
+

DASHBOARD

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

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

+ {showJobs && ( +
+ +
+ )} +
+ + {/* Recent Projects */} +
+

setShowProjects(!showProjects)} + > + {showProjects ? : } Recent + Projects +

+ {showProjects && ( +
+ +
+ )} +
+ + {/* My Tickets */} +
+

setShowTickets(!showTickets)} + > + {showTickets ? : } My Tickets +

+ {showTickets && ( +
+ +
+ )} +
+ + {/* Allocations */} +
+

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

+ {showAllocations && } +
+
+ + {/* Vertical Divider */} +
+ + {/* Right Panel */} +
+
+

System Status

+ ({ + key: sys.hostname, + ...sys, + }))} + loading={isLoading} + size="small" + pagination={false} + /> + + +
+

Recently Accessed Tools

+ +
+ + + + + ); } diff --git a/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx b/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx new file mode 100644 index 0000000000..147d2dfc5f --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/FavoriteTools.tsx @@ -0,0 +1,158 @@ +import React, { useState } from 'react'; +import { Typography, List, Button } from 'antd'; +import { useFavorites, useRemoveFavorite, useAppsListing } from '@client/hooks'; +import type { TPortalApp } from '@client/hooks'; + +const { Link, Text } = Typography; + +interface Favorite { + app_id: string; + version?: string; + id: string; +} + +interface RecentTool { + label: string; + path: string; +} + +const parseToolId = (tool_id: string): { app_id: string; version?: string } => { + const parts = tool_id.split('-'); + if (parts.length > 1 && /^\d+(\.\d+)*$/.test(parts[parts.length - 1])) { + return { + app_id: parts.slice(0, -1).join('-'), + version: parts[parts.length - 1], + }; + } + return { app_id: tool_id }; +}; + +const makeFavoriteKey = (fav: Favorite) => + fav.version ? `${fav.app_id}-${fav.version}` : fav.app_id; + +const QuickLinksMenu: React.FC = () => { + const [showFavorites, setShowFavorites] = useState(false); + const { data: favoritesData, isLoading: loadingFavs } = useFavorites(); + const { data: appsData, isLoading: loadingApps } = useAppsListing(); + const removeFavoriteMutation = useRemoveFavorite(); + const [removingIds, setRemovingIds] = useState>(new Set()); + + if (loadingFavs || loadingApps) return
Loading favorites...
; + + const allApps: TPortalApp[] = + appsData?.categories.flatMap((cat) => cat.apps) ?? []; + + const favorites: Favorite[] = (favoritesData ?? []).map((fav) => { + const { app_id, version } = parseToolId(fav.tool_id); + return { app_id, version, id: fav.tool_id }; + }); + + const resolvedFavorites = favorites + .map((fav) => { + const matchedApp = allApps.find( + (app) => + app.app_id === fav.app_id && + (!fav.version || app.version === fav.version) + ); + if (!matchedApp) return null; + return { + key: makeFavoriteKey(fav), + app: matchedApp, + id: fav.id, + }; + }) + .filter(Boolean) as { key: string; app: TPortalApp; id: string }[]; + + const handleRemove = async (key: string) => { + if (removingIds.has(key)) return; + + setRemovingIds((prev) => new Set(prev).add(key)); + + try { + await removeFavoriteMutation.mutateAsync(key); + } catch (error) { + console.error('Failed to remove favorite:', error); + } finally { + setRemovingIds((prev) => { + const newSet = new Set(prev); + newSet.delete(key); + return newSet; + }); + } + }; + + const handleToolClick = (app: TPortalApp) => { + const href = app.version + ? `/workspace/${app.app_id}?appVersion=${app.version}` + : `/workspace/${app.app_id}`; + + const recent: RecentTool[] = JSON.parse( + localStorage.getItem('recentTools') ?? '[]' + ); + const updated = [ + { label: app.label, path: href }, + ...recent.filter((r) => r.path !== href), + ].slice(0, 5); + localStorage.setItem('recentTools', JSON.stringify(updated)); + + window.open(href, '_blank', 'noopener,noreferrer'); + }; + + return ( + + ); +}; + +export default QuickLinksMenu; 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/QuickLinksNavbar.tsx b/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx new file mode 100644 index 0000000000..df44f973a7 --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/QuickLinksNavbar.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import styles from './Dashboard.module.css'; +import FavoriteTools from './FavoriteTools'; + +const Quicklinks = () => { + return ( +
+
Quick Links
+ + {/* Favorite Tools */} +
+ +
+ + + + Manage Account + + + + + Tools & Applications + + + + + Training + +
+ ); +}; + +export default Quicklinks; 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/RecentProjects.tsx b/client/modules/dashboard/src/Dashboard/RecentProjects.tsx new file mode 100644 index 0000000000..30d733c61b --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/RecentProjects.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useState } from 'react'; +import axios from 'axios'; +import { Table, Typography } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import styles from './Dashboard.module.css'; + +type Project = { + uuid: string; + title: string; + projectId: string; + lastUpdated: string; + pi: string; +}; + +interface RawUser { + role: string; + fname: string; + lname: string; +} + +interface RawProject { + uuid: string; + lastUpdated: string; + value: { + title: string; + projectId: string; + users?: RawUser[]; + }; +} + +const { Link, Text } = Typography; + +const RecentProjects: React.FC = () => { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const fetchProjects = async () => { + setLoading(true); + try { + const response = await axios.get( + '/api/projects/v2/?offset=0&limit=100' + ); + const rawProjects: RawProject[] = response.data.result; + + const mapped: Project[] = rawProjects.map((proj) => { + const piUser = proj.value.users?.find((user) => user.role === 'pi'); + + return { + uuid: proj.uuid, + title: proj.value.title, + projectId: proj.value.projectId, + lastUpdated: proj.lastUpdated, + pi: piUser ? `${piUser.fname} ${piUser.lname}` : 'N/A', + }; + }); + + const sortedRecent = mapped + .sort( + (a, b) => + new Date(b.lastUpdated).getTime() - + new Date(a.lastUpdated).getTime() + ) + .slice(0, 3); + + setProjects(sortedRecent); + } catch (error) { + console.error('Failed to fetch recent projects!', error); + } finally { + setLoading(false); + } + }; + + fetchProjects(); + }, []); + + if (projects.length === 0) return null; + + const columns: ColumnsType = [ + { + title: 'Title', + dataIndex: 'title', + key: 'title', + render: (text: string, record: Project) => ( + + {text || record.projectId} + + ), + }, + { + title: 'PI', + dataIndex: 'pi', + key: 'pi', + }, + { + title: 'ID', + dataIndex: 'projectId', + key: 'projectId', + className: styles.projectId, + render: (text: string) => {text}, + }, + ]; + + return ( +
+
+ + ); +}; + +export default RecentProjects; diff --git a/client/modules/dashboard/src/Dashboard/RecentlyAccessed.tsx b/client/modules/dashboard/src/Dashboard/RecentlyAccessed.tsx new file mode 100644 index 0000000000..642bb3877c --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/RecentlyAccessed.tsx @@ -0,0 +1,49 @@ +import React, { useEffect, useState } from 'react'; +import { List, Typography } from 'antd'; +import styles from './Dashboard.module.css'; + +type RecentTool = { + label: string; + path: string; +}; + +const RecentlyAccessed: React.FC = () => { + const [recentTools, setRecentTools] = useState([]); + + useEffect(() => { + const stored = localStorage.getItem('recentTools'); + if (stored) { + try { + const parsed: RecentTool[] = JSON.parse(stored); + setRecentTools(parsed); + } catch (e) { + console.error('Failed to parse recentTools:', e); + setRecentTools([]); + } + } + }, []); + + if (recentTools.length === 0) return null; + + return ( +
+ ( + { + window.location.href = tool.path; + }} + > + {tool.label} + + )} + /> +
+ ); +}; + +export default RecentlyAccessed; 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/TicketList.tsx b/client/modules/dashboard/src/Dashboard/TicketList.tsx new file mode 100644 index 0000000000..7642d34636 --- /dev/null +++ b/client/modules/dashboard/src/Dashboard/TicketList.tsx @@ -0,0 +1,254 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import axios from 'axios'; +import { + Table, + Button, + Input, + Alert, + Space, + Typography, + Spin, + Modal, +} from 'antd'; +import { + CloseOutlined, + CommentOutlined, + PlusOutlined, +} from '@ant-design/icons'; +import styles from './Dashboard.module.css'; + +interface RawTicket { + id: number; + Subject?: string; + subject?: string; + Status?: string; + status?: string; + created_at?: string; + Created?: string; + updated_at?: string; + LastUpdated?: string; +} + +interface NormalizedTicket { + id: number; + subject: string; + status: string; + created_at: string; + updated_at?: string; +} + +export const TicketList: React.FC = () => { + const [tickets, setTickets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filter, setFilter] = useState(''); + const [showResolved, setShowResolved] = useState(false); + + // Normalize status: replace unknown or unexpected values with 'new' + const normalizeStatus = (status?: string) => { + if (!status) return 'unknown'; + const s = status.toLowerCase().trim(); + const allowedStatuses = ['new', 'open', 'pending', 'resolved', 'closed']; + return allowedStatuses.includes(s) ? s : 'new'; + }; + + const normalizeTicket = useCallback( + (ticket: RawTicket): NormalizedTicket => ({ + id: ticket.id, + subject: ticket.subject || ticket.Subject || 'No Subject', + status: normalizeStatus(ticket.status || ticket.Status), + created_at: ticket.created_at || ticket.Created || '', + updated_at: ticket.updated_at || ticket.LastUpdated, + }), + [] + ); + + const fetchTickets = useCallback(async () => { + setLoading(true); + setError(null); + + try { + const res = await axios.get('/help/tickets/', { + params: { + fmt: 'json', + show_resolved: true, + }, + }); + const normalized = res.data.map((ticket: RawTicket) => + normalizeTicket(ticket) + ); + setTickets(normalized); + } catch (e) { + setError('Failed to load tickets.'); + } finally { + setLoading(false); + } + }, [normalizeTicket]); + + useEffect(() => { + fetchTickets(); + }, [fetchTickets]); + + const formatDate = (input?: string) => { + if (!input) return 'N/A'; + const normalized = input.includes('T') ? input : input.replace(' ', 'T'); + const date = new Date(normalized); + return isNaN(date.getTime()) ? 'N/A' : date.toLocaleString(); + }; + + const isResolved = (status: string) => { + const s = status.toLowerCase().trim(); + return s === 'resolved' || s === 'closed'; + }; + + const filteredTickets = tickets.filter((ticket) => { + const matchesFilter = + ticket.subject.toLowerCase().includes(filter.toLowerCase()) || + ticket.id.toString().includes(filter); + + return matchesFilter && showResolved === isResolved(ticket.status); + }); + + const handleClose = (ticketId: number) => { + Modal.confirm({ + title: 'Confirm Close', + content: 'Are you sure you want to close this ticket?', + okText: 'Yes', + cancelText: 'No', + onOk: async () => { + try { + await axios.post(`/help/tickets/${ticketId}/close/`); + fetchTickets(); + } catch { + Modal.error({ + title: 'Error', + content: 'Failed to close ticket.', + }); + } + }, + }); + }; + + const columns = [ + { + title: 'Status', + dataIndex: 'status', + key: 'status', + width: 120, + render: (status: string) => ( + + {status} + + ), + }, + { + title: 'Ticket ID / Subject', + dataIndex: 'subject', + key: 'subject', + render: (_: unknown, record: NormalizedTicket) => ( + + {record.id} / {record.subject} + + ), + }, + { + title: 'Last Updated', + dataIndex: 'updated_at', + key: 'updated_at', + width: 180, + render: (date: string | undefined, record: NormalizedTicket) => + formatDate(date ?? record.created_at), + }, + { + title: 'Actions', + key: 'actions', + width: 160, + render: (_: unknown, record: NormalizedTicket) => { + const resolved = isResolved(record.status); + return ( + + + {!resolved && ( + + )} + + ); + }, + }, + ]; + + return ( +
+
+ +
+ +
+ + + setFilter(e.target.value)} + style={{ maxWidth: 300 }} + /> +
+ + {loading ? ( +
+ +
+ ) : error ? ( + + ) : filteredTickets.length === 0 ? ( + + ) : ( +
+ + + )} + + ); +}; 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) => ( + + ))} +
+ + +
+ ); +}; + +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/datafiles/src/DatafilesToolbar/DatafilesToolbar.tsx b/client/modules/datafiles/src/DatafilesToolbar/DatafilesToolbar.tsx index aa337a3a5e..df11b49073 100644 --- a/client/modules/datafiles/src/DatafilesToolbar/DatafilesToolbar.tsx +++ b/client/modules/datafiles/src/DatafilesToolbar/DatafilesToolbar.tsx @@ -251,6 +251,27 @@ export const DatafilesToolbar: React.FC<{ searchInput?: React.ReactNode }> = ({ )} + { + fetch('/api/storage/analyze/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + .then((res) => res.json()) + .then((data) => { + console.log('Analyze results:', data); + alert('Analyze complete. Check console for results.'); + }) + .catch((err) => { + console.error('Analyze error:', err); + alert('Analyze failed. See console.'); + }); + }} + className={styles.toolbarButton} + > + + Analyze Storage + ); diff --git a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx index c635099b1a..574808a2bf 100644 --- a/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx +++ b/client/modules/workspace/src/AppsSideNav/AppsSideNav.tsx @@ -1,133 +1,205 @@ -import React from 'react'; -import { Menu, MenuProps } from 'antd'; +import React, { useState } from 'react'; +import { Menu, MenuProps, Switch } from 'antd'; import { NavLink } from 'react-router-dom'; -import { TAppCategory, TPortalApp } from '@client/hooks'; +import { + TAppCategory, + TPortalApp, + useFavorites, + useAddFavorite, + useRemoveFavorite, +} from '@client/hooks'; import { useGetAppParams } from '../utils'; export const AppsSideNav: React.FC<{ categories: TAppCategory[] }> = ({ categories, }) => { + const { data: favoritesData = [], isLoading: isLoadingFavorites } = + useFavorites(); + + const addFavoriteMutation = useAddFavorite(); + const removeFavoriteMutation = useRemoveFavorite(); + + const [updatingToolIds, setUpdatingToolIds] = useState>( + new Set() + ); + const { appId, appVersion } = useGetAppParams(); + + if (isLoadingFavorites) { + return ( +
+
+ Applications: +
+
Loading favorites...
+
+ ); + } + + const favoriteToolIds = Array.isArray(favoritesData) + ? favoritesData.map((fav) => fav.tool_id) + : []; + + const handleStarClick = async (toolId: string) => { + const isFavorite = favoriteToolIds.includes(toolId); + setUpdatingToolIds((prev) => new Set(prev).add(toolId)); + + try { + if (isFavorite) { + await removeFavoriteMutation.mutateAsync(toolId); + } else { + await addFavoriteMutation.mutateAsync(toolId); + } + } catch (err) { + console.error('Failed to update favorites', err); + } finally { + setUpdatingToolIds((prev) => { + const newSet = new Set(prev); + newSet.delete(toolId); + return newSet; + }); + } + }; + type MenuItem = Required['items'][number] & { priority: number }; - function getItem( + const getItem = ( label: React.ReactNode, key: string, priority: number, children?: MenuItem[], type?: 'group' - ): MenuItem { - return { - label, - key, - priority, - children, - type, - } as MenuItem; - } + ): MenuItem => ({ + label, + key, + priority, + children, + ...(type === 'group' ? { type } : {}), + }); - const getCategoryApps = (category: TAppCategory) => { - const bundles: { - [dynamic: string]: { - apps: MenuItem[]; - label: string; - }; - } = {}; + const getCategoryApps = (category: TAppCategory): MenuItem[] => { + const bundles: Record = {}; const categoryItems: MenuItem[] = []; category.apps.forEach((app) => { + const toolId = app.version ? `${app.app_id}-${app.version}` : app.app_id; + const isFavorite = favoriteToolIds.includes(toolId); + const linkPath = `${app.app_id}${ + app.version ? `?appVersion=${app.version}` : '' + }`; + const linkLabel = + app.shortLabel || app.label || app.bundle_label || 'Unknown'; + + const switchControl = ( + e.stopPropagation()} style={{ marginLeft: 6 }}> + handleStarClick(toolId)} + checkedChildren="★" + unCheckedChildren="☆" + /> + + ); + + const labelContent = ( +
+ handleToolClick(linkLabel, linkPath)} + style={{ + flex: 1, + whiteSpace: 'normal', + overflowWrap: 'break-word', + wordBreak: 'break-word', + color: 'inherit', + textDecoration: 'none', + }} + > + {linkLabel} + + {switchControl} +
+ ); + + const item = getItem(labelContent, toolId, app.priority); + if (app.is_bundled) { const bundleKey = `${app.bundle_label}${app.bundle_id}`; - if (bundles[bundleKey]) { - bundles[bundleKey].apps.push( - getItem( - - {app.shortLabel || app.label || app.bundle_label} - , - `${app.app_id}${app.version}${app.bundle_id}`, - app.priority - ) - ); - } else { + if (!bundles[bundleKey]) { bundles[bundleKey] = { - apps: [ - getItem( - - {app.shortLabel || app.label || app.bundle_label} - , - `${app.app_id}${app.version}${app.bundle_id}`, - app.priority - ), - ], - label: app.bundle_label, + apps: [], + label: app.bundle_label || 'Bundle', }; } + bundles[bundleKey].apps.push(item); } else { - categoryItems.push( - getItem( - - {app.shortLabel || app.label || app.bundle_label} - , - `${app.app_id}${app.version}${app.bundle_id}`, - app.priority - ) - ); + categoryItems.push(item); } }); + const bundleItems = Object.entries(bundles).map( - ([bundleKey, bundle], index) => + ([bundleKey, bundle], idx) => getItem( `${bundle.label} [${bundle.apps.length}]`, bundleKey, - index, + idx, bundle.apps.sort((a, b) => a.priority - b.priority) ) ); - return categoryItems - .concat(bundleItems) - .sort((a, b) => (a?.key as string).localeCompare(b?.key as string)); + return [ + ...categoryItems.sort((a, b) => a.priority - b.priority), + ...bundleItems, + ]; }; - const items: MenuItem[] = categories.map((category) => { - return getItem( - `${category.title} [${category.apps.length}]`, - category.title, - category.priority, - getCategoryApps(category) - ); - }); - - const { appId, appVersion } = useGetAppParams(); + const items: MenuItem[] = categories + .map((category) => + getItem( + `${category.title} [${category.apps.length}]`, + category.title, + category.priority, + getCategoryApps(category) + ) + ) + .sort((a, b) => a.priority - b.priority); const currentApp = categories - .map((cat) => cat.apps) - .flat() + .flatMap((cat) => cat.apps) .find((app) => app.app_id === appId && app.version === (appVersion || '')); + const currentCategory = categories.find((cat) => cat.apps.includes(currentApp as TPortalApp) ); + const currentSubMenu = currentApp?.is_bundled ? `${currentApp.bundle_label}${currentApp.bundle_id}` : ''; - const selectedKey = `${appId}${appVersion || ''}${currentApp?.bundle_id}`; + + const selectedKey = appVersion ? `${appId}-${appVersion}` : appId; return ( - <> +
= ({
- +
+ ); +}; + +const handleToolClick = (toolName: string, toolPath: string) => { + const correctedPath = toolPath.startsWith('/workspace/') + ? toolPath + : `/workspace/${toolPath.replace(/^\//, '')}`; + const existing: { label: string; path: string }[] = JSON.parse( + localStorage.getItem('recentTools') || '[]' ); + const updated = [ + { label: toolName, path: correctedPath }, + ...existing.filter((t) => t.path !== correctedPath), + ].slice(0, 5); + localStorage.setItem('recentTools', JSON.stringify(updated)); }; 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..f793b76859 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,7 +1,7 @@ import './styles.css'; import { StrictMode } from 'react'; import * as ReactDOM from 'react-dom/client'; -import { RouterProvider } from 'react-router-dom'; +import { BrowserRouter, RouterProvider } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import workspaceRouter from './workspace/workspaceRouter'; import datafilesRouter from './datafiles/datafilesRouter'; @@ -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(); @@ -99,7 +101,9 @@ if (dashboardElement) { - + + + diff --git a/designsafe/apps/api/datafiles/models.py b/designsafe/apps/api/datafiles/models.py index b4ee5868dc..1c28b24619 100644 --- a/designsafe/apps/api/datafiles/models.py +++ b/designsafe/apps/api/datafiles/models.py @@ -29,4 +29,4 @@ class PublicationSymlink(models.Model): ) def __str__(self): - return f"{self.tapis_accessor} -> {self.type}" + return f"{self.tapis_accessor} -> {self.type}" \ No newline at end of file diff --git a/designsafe/apps/api/datafiles/urls.py b/designsafe/apps/api/datafiles/urls.py index 68d85d93c5..02a4f6fdcc 100644 --- a/designsafe/apps/api/datafiles/urls.py +++ b/designsafe/apps/api/datafiles/urls.py @@ -11,4 +11,4 @@ url(r'^(?P[\w.-]+)/(?P[\w.-]+)/(?P[\w.-]+)/(?P[\w.-]+)/$', DataFilesView.as_view(), name='agave_files'), url(r'^microsurvey/$', MicrosurveyView.as_view(), name='microsurvey') -] +] \ No newline at end of file diff --git a/designsafe/apps/api/datafiles/utils.py b/designsafe/apps/api/datafiles/utils.py index d19a6ce1bb..c0740a7d19 100644 --- a/designsafe/apps/api/datafiles/utils.py +++ b/designsafe/apps/api/datafiles/utils.py @@ -169,4 +169,4 @@ def create_meta(path, system, meta): system=system, meta=meta ) - sa_client.meta.addMetadata(body=json.dumps(meta_body)) + sa_client.meta.addMetadata(body=json.dumps(meta_body)) \ No newline at end of file diff --git a/designsafe/apps/api/datafiles/views.py b/designsafe/apps/api/datafiles/views.py index 90b90e8914..dea547a5fa 100644 --- a/designsafe/apps/api/datafiles/views.py +++ b/designsafe/apps/api/datafiles/views.py @@ -203,4 +203,4 @@ def put(self, request): counter = DataFilesSurveyCounter.objects.all()[0] counter.count += 1 counter.save() - return JsonResponse({'show': (counter.count % 7 == 0)}) + return JsonResponse({'show': (counter.count % 7 == 0)}) \ No newline at end of file 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/urls.py b/designsafe/apps/workspace/api/urls.py index 1b239767a8..028b2a3aaa 100644 --- a/designsafe/apps/workspace/api/urls.py +++ b/designsafe/apps/workspace/api/urls.py @@ -1,5 +1,4 @@ -"""Workpace API Urls -""" +"""Workpace API Urls""" from django.urls import path, re_path from designsafe.apps.workspace.api import views @@ -19,4 +18,12 @@ re_path(r"^jobs/(?P\w+)/?$", views.JobsView.as_view()), path("jobs", views.JobsView.as_view()), path("allocations", views.AllocationsView.as_view()), + path( + "user-favorites/", views.UserFavoriteList.as_view(), name="user_favorite_list" + ), + path( + "user-favorites/remove/", + views.RemoveFavoriteTool.as_view(), + name="remove_favorite_tool", + ), ] diff --git a/designsafe/apps/workspace/api/views.py b/designsafe/apps/workspace/api/views.py index 67b60b5685..881b928b34 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 @@ -28,7 +27,10 @@ from designsafe.apps.api.users.utils import get_allocations from designsafe.apps.workspace.api.utils import check_job_for_timeout from designsafe.apps.onboarding.steps.system_access_v3 import create_system_credentials - +from django.views import View +from django.http import JsonResponse, HttpResponseBadRequest +from django.contrib.auth.mixins import LoginRequiredMixin +from designsafe.apps.workspace.models.user_favorites import UserFavorite logger = logging.getLogger(__name__) METRICS = logging.getLogger(f"metrics.{__name__}") @@ -56,7 +58,6 @@ }, } - def _app_license_type(app_def): """Gets an app's license type, if any.""" @@ -64,7 +65,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 +77,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 +92,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 +111,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 +128,6 @@ def test_system_access_ok( ) raise - def test_system_needs_keys( tapis: object, username: str, system_id: str, path: str = "/" ) -> bool: @@ -177,7 +173,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 +210,6 @@ def get(self, request, system_id): {"status": 200, "response": system_def}, encoder=BaseTapisResultSerializer ) - class AppsView(AuthenticatedApiView): """View for Tapis app listings.""" @@ -262,7 +256,6 @@ def get(self, request, *args, **kwargs): encoder=BaseTapisResultSerializer, ) - class AppsTrayView(AuthenticatedApiView): """Views for Workspace Apps Tray listings.""" @@ -451,6 +444,7 @@ def _get_public_apps(self, user, verbose): categories.append(category_result) return categories, html_definitions + def get(self, request, *args, **kwargs): """ @@ -523,7 +517,6 @@ def get(self, request, *args, **kwargs): encoder=BaseTapisResultSerializer, ) - class AppDescriptionView(AuthenticatedApiView): """Views for retreiving AppDescription objects.""" @@ -536,7 +529,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 +885,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,10 +900,46 @@ def get(self, request): for allocation in allocations if allocation not in settings.ALLOCATIONS_TO_EXCLUDE ] - return JsonResponse( { "status": 200, "response": data, } ) + + +class UserFavoriteList(LoginRequiredMixin, View): + def get(self, request): + favorites = UserFavorite.objects.filter(user=request.user) + data = [ + {"id": fav.id, "tool_id": fav.tool_id, "added_on": fav.added_on.isoformat()} + for fav in favorites + ] + return JsonResponse(data, safe=False) + + def post(self, request): + try: + data = json.loads(request.body) + tool_id = data.get("tool_id") + if not tool_id: + return HttpResponseBadRequest("Missing tool_id") + favorite, created = UserFavorite.objects.get_or_create( + user=request.user, tool_id=tool_id + ) + return JsonResponse({"success": True, "created": created}) + except Exception as e: + return JsonResponse({"success": False, "error": str(e)}) + + + +class RemoveFavoriteTool(LoginRequiredMixin, View): + def post(self, request): + try: + data = json.loads(request.body) + tool_id = data.get("tool_id") + if not tool_id: + return HttpResponseBadRequest("Missing tool_id") + UserFavorite.objects.filter(user=request.user, tool_id=tool_id).delete() + return JsonResponse({"success": True}) + except Exception as e: + return JsonResponse({"success": False, "error": str(e)}) diff --git a/designsafe/apps/workspace/migrations/0019_userfavorite.py b/designsafe/apps/workspace/migrations/0019_userfavorite.py new file mode 100644 index 0000000000..cfecec6159 --- /dev/null +++ b/designsafe/apps/workspace/migrations/0019_userfavorite.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.20 on 2025-06-30 16:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("workspace", "0018_appvariant_external_href"), + ] + + operations = [ + migrations.CreateModel( + name="UserFavorite", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("tool_id", models.CharField(max_length=100)), + ("added_on", models.DateTimeField(auto_now_add=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("user", "tool_id")}, + }, + ), + ] diff --git a/designsafe/apps/workspace/models/user_favorites.py b/designsafe/apps/workspace/models/user_favorites.py new file mode 100644 index 0000000000..68133f8ee7 --- /dev/null +++ b/designsafe/apps/workspace/models/user_favorites.py @@ -0,0 +1,24 @@ +""" +Models related to user favorites functionality. +""" + +from django.db import models +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class UserFavorite(models.Model): + """Model to store user favorite tools.""" + + user = models.ForeignKey(User, on_delete=models.CASCADE) + tool_id = models.CharField( + max_length=100 + ) # Consider UUIDField if tool IDs are UUIDs + added_on = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("user", "tool_id") # Prevent duplicates + + def __str__(self): + return f"{self.user.username} - {self.tool_id}" diff --git a/designsafe/settings/common_settings.py b/designsafe/settings/common_settings.py index 1f41db5dd8..080c38bc34 100644 --- a/designsafe/settings/common_settings.py +++ b/designsafe/settings/common_settings.py @@ -57,7 +57,6 @@ 'django.contrib.sites', 'django.contrib.sitemaps', 'django.contrib.staticfiles', - 'cmsplugin_cascade', 'cmsplugin_cascade.extra_fields', diff --git a/designsafe/static/scripts/data-depot/components/data-depot-toolbar/data-depot-toolbar.component.html b/designsafe/static/scripts/data-depot/components/data-depot-toolbar/data-depot-toolbar.component.html index a5fb8d4b13..1c0f9b0823 100644 --- a/designsafe/static/scripts/data-depot/components/data-depot-toolbar/data-depot-toolbar.component.html +++ b/designsafe/static/scripts/data-depot/components/data-depot-toolbar/data-depot-toolbar.component.html @@ -45,6 +45,8 @@ + +