diff --git a/package-lock.json b/package-lock.json index cdeb3b2..b6050b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@mui/material": "^7.3.10", "@tanstack/react-query": "^5.100.9", "@tanstack/react-query-devtools": "^5.100.9", + "@tanstack/react-virtual": "^3.14.6", "dayjs": "^1.11.20", "immer": "^11.1.11", "next": "^15.5.15", @@ -2175,6 +2176,33 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.6", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.6.tgz", + "integrity": "sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.4.tgz", + "integrity": "sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", diff --git a/package.json b/package.json index 5170499..842bcfd 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@mui/material": "^7.3.10", "@tanstack/react-query": "^5.100.9", "@tanstack/react-query-devtools": "^5.100.9", + "@tanstack/react-virtual": "^3.14.6", "dayjs": "^1.11.20", "immer": "^11.1.11", "next": "^15.5.15", diff --git a/src/app/api/sessions/route.ts b/src/app/api/sessions/route.ts index 4d1c681..22a0506 100644 --- a/src/app/api/sessions/route.ts +++ b/src/app/api/sessions/route.ts @@ -46,11 +46,26 @@ export const GET = withErrorHandling(async (request: NextRequest) => { }; console.log('📨 Session GET route - final headers:', finalHeaders); - // `view=interactive` excludes headless batch jobs. Skaha source: - // skaha/src/main/java/org/opencadc/skaha/session/GetAction.java — - // SESSION_VIEW_INTERACTIVE branch. + const sessionType = request.nextUrl.searchParams.get('type'); + const isHeadlessList = sessionType === 'headless'; + const statusFilter = request.nextUrl.searchParams.get('status'); + + // Interactive: `view=interactive` excludes headless batch jobs (Skaha + // SESSION_VIEW_INTERACTIVE). Headless: `type=headless` on the typed list + // path — keep Failed/Succeeded/Completed for the batch-jobs widget. + // Optional `status` narrows to one Skaha status (Pending, Running, …). + let skahaUrl: string; + if (isHeadlessList) { + skahaUrl = `${serverApiConfig.skaha.baseUrl}/v1/session?type=headless`; + if (statusFilter) { + skahaUrl += `&status=${encodeURIComponent(statusFilter)}`; + } + } else { + skahaUrl = `${serverApiConfig.skaha.baseUrl}/v1/session?view=interactive`; + } + const response = await fetchExternalApi( - `${serverApiConfig.skaha.baseUrl}/v1/session?view=interactive`, + skahaUrl, { method: 'GET', headers: finalHeaders, @@ -68,11 +83,24 @@ export const GET = withErrorHandling(async (request: NextRequest) => { return errorResponse('Failed to fetch sessions', response.status, errorText); } + const all: SkahaSessionResponse[] = await response.json(); + + if (isHeadlessList) { + logger.info( + `Retrieved ${all.length} headless session(s)${statusFilter ? ` status=${statusFilter}` : ''}`, + ); + logger.logSuccess(HTTP_STATUS.OK, { + count: all.length, + type: 'headless', + ...(statusFilter ? { status: statusFilter } : {}), + }); + return successResponse(all); + } + // Skaha marks a session `Failed` once its lifetime elapses (an expired // session). These linger in the list for some time after they stop being // useful; drop them so the active-sessions widget only shows sessions // the user can actually interact with. - const all: SkahaSessionResponse[] = await response.json(); const sessions = all.filter((s) => s.status !== 'Failed'); logger.info( `Retrieved ${all.length} interactive session(s), ${sessions.length} after dropping expired`, diff --git a/src/app/components/HeadlessSessionsWidget/HeadlessSessionsWidget.tsx b/src/app/components/HeadlessSessionsWidget/HeadlessSessionsWidget.tsx new file mode 100644 index 0000000..85833d3 --- /dev/null +++ b/src/app/components/HeadlessSessionsWidget/HeadlessSessionsWidget.tsx @@ -0,0 +1,6 @@ +import { HeadlessSessionsWidgetProps } from '@/app/types/HeadlessSessionsWidgetProps'; +import { HeadlessSessionsWidgetImpl } from '@/app/implementation/headlessSessionsWidget'; + +export function HeadlessSessionsWidget(props: HeadlessSessionsWidgetProps) { + return ; +} diff --git a/src/app/implementation/headlessSessionsWidget.tsx b/src/app/implementation/headlessSessionsWidget.tsx new file mode 100644 index 0000000..8a31ba3 --- /dev/null +++ b/src/app/implementation/headlessSessionsWidget.tsx @@ -0,0 +1,486 @@ +'use client'; + +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + Box, + Button, + Chip, + FormControl, + FormControlLabel, + IconButton, + MenuItem, + Select, + Skeleton, + Switch, + Tab, + Tabs, + Tooltip, + Typography, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import { + Delete as DeleteIcon, + Description as LogsIcon, + Flag as FlagIcon, + Refresh as RefreshIcon, +} from '@mui/icons-material'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { DashboardWidget } from '@/app/components/DashboardWidget/DashboardWidget'; +import type { + HeadlessAutoRefreshIntervalSec, + HeadlessSessionsWidgetProps, +} from '@/app/types/HeadlessSessionsWidgetProps'; +import { HEADLESS_AUTO_REFRESH_INTERVALS_SEC } from '@/app/types/HeadlessSessionsWidgetProps'; +import type { Session } from '@/lib/api/skaha'; +import { useSessionModalsActions } from '@/lib/stores'; +import { + filterHeadlessJobsByGroup, + groupHeadlessJobsByState, + type HeadlessJobGroup, +} from '@/lib/sessions/headlessJobs'; +import { hasAssignedSessionId } from '@/lib/sessions/sessionQuota'; + +const EMPTY_OPERATING_IDS: Map = new Map(); + +const DEFAULT_INTERVAL_SEC: HeadlessAutoRefreshIntervalSec = 45; +const LIST_MAX_HEIGHT = 150; +const ROW_HEIGHT = 44; + +const GROUP_TABS: { id: HeadlessJobGroup; label: string; color: string }[] = [ + { id: 'pending', label: 'Pending', color: '#FF9800' }, + { id: 'running', label: 'Running', color: '#4CAF50' }, + { id: 'completed', label: 'Completed', color: '#2196F3' }, + { id: 'failed', label: 'Failed', color: '#F44336' }, +]; + +function formatIntervalLabel(sec: number): string { + if (sec < 60) return `${sec}s`; + const mins = sec / 60; + return Number.isInteger(mins) ? `${mins}m` : `${sec}s`; +} + +function shortImageLabel(image: string): string { + if (!image) return 'N/A'; + const parts = image.split('/'); + return parts.length >= 2 ? parts.slice(-2).join('/') : image; +} + +function statusChipColor( + status: Session['status'], +): 'success' | 'warning' | 'error' | 'info' | 'default' { + switch (status) { + case 'Running': + return 'success'; + case 'Pending': + case 'Terminating': + return 'warning'; + case 'Failed': + case 'Error': + return 'error'; + case 'Succeeded': + case 'Completed': + return 'info'; + default: + return 'default'; + } +} + +interface HeadlessJobRowProps { + session: Session; + isTerminating: boolean; + onEvents: () => void; + onLogs: () => void; + onDelete: () => void; +} + +const HeadlessJobRow = React.memo(function HeadlessJobRow({ + session, + isTerminating, + onEvents, + onLogs, + onDelete, +}: HeadlessJobRowProps) { + const theme = useTheme(); + const actionsDisabled = isTerminating || !hasAssignedSessionId(session.id); + const imageLabel = shortImageLabel(session.containerImage); + + return ( + + + {session.id} + + + + {session.sessionName} + + + + {imageLabel} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); + +interface VirtualizedJobListProps { + jobs: Session[]; + operatingSessionIds: Map; + onOpenModal: (session: Session, kind: 'events' | 'logs' | 'delete') => void; + /** Reset scroll position when the active status tab changes. */ + scrollResetKey: HeadlessJobGroup; +} + +function VirtualizedJobList({ + jobs, + operatingSessionIds, + onOpenModal, + scrollResetKey, +}: VirtualizedJobListProps) { + const theme = useTheme(); + const parentRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: jobs.length, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 6, + }); + + useEffect(() => { + parentRef.current?.scrollTo({ top: 0 }); + }, [scrollResetKey]); + + return ( + + + {virtualizer.getVirtualItems().map((virtualRow) => { + const session = jobs[virtualRow.index]; + const operation = operatingSessionIds.get(session.id); + const isTerminating = operation === 'delete' || session.status === 'Terminating'; + + return ( + + onOpenModal(session, 'events')} + onLogs={() => onOpenModal(session, 'logs')} + onDelete={() => onOpenModal(session, 'delete')} + /> + + ); + })} + + + ); +} + +export function HeadlessSessionsWidgetImpl({ + sessions = [], + operatingSessionIds = EMPTY_OPERATING_IDS, + isLoading = false, + isFetching = false, + hasLoaded = false, + groupLoading = {}, + groupLoaded = {}, + onRefresh, + onAutoRefreshSettingsChange, + title = 'Headless Sessions', + showSessionCount = true, + emptyMessage = 'No headless jobs in this group', + idleMessage = 'Click Fetch now or enable auto-refresh to load headless jobs', +}: HeadlessSessionsWidgetProps) { + const theme = useTheme(); + const { openSessionModal } = useSessionModalsActions(); + const [group, setGroup] = useState('pending'); + const [autoRefresh, setAutoRefresh] = useState(false); + const [intervalSec, setIntervalSec] = + useState(DEFAULT_INTERVAL_SEC); + + useEffect(() => { + onAutoRefreshSettingsChange?.({ enabled: autoRefresh, intervalSec }); + }, [autoRefresh, intervalSec, onAutoRefreshSettingsChange]); + + const counts = useMemo(() => groupHeadlessJobsByState(sessions), [sessions]); + const visibleJobs = useMemo( + () => filterHeadlessJobsByGroup(sessions, group), + [sessions, group], + ); + + const currentGroupLoading = Boolean(groupLoading[group]); + const currentGroupLoaded = Boolean(groupLoaded[group]); + const showGroupSkeleton = + hasLoaded && currentGroupLoading && visibleJobs.length === 0 && !currentGroupLoaded; + + const displayTitle = + showSessionCount && sessions.length > 0 ? `${title} (${sessions.length})` : title; + const isBusy = isLoading || isFetching; + + const openModal = (session: Session, kind: 'events' | 'logs' | 'delete') => { + if (!hasAssignedSessionId(session.id)) return; + openSessionModal({ + sessionId: session.id, + sessionName: session.sessionName, + kind, + }); + }; + + return ( + + + setAutoRefresh(checked)} + inputProps={{ 'aria-label': 'Auto-refresh headless sessions' }} + /> + } + label="Auto-refresh" + sx={{ mr: 0.5 }} + /> + + + + + + + + + + setGroup(value)} + variant="scrollable" + scrollButtons="auto" + aria-label="headless job status groups" + > + {GROUP_TABS.map((tab) => { + const loaded = Boolean(groupLoaded[tab.id]); + const countLabel = !hasLoaded + ? '0' + : loaded + ? String(counts[tab.id]) + : '…'; + return ( + + + + {tab.label} ({countLabel}) + + + } + /> + ); + })} + + + + {isLoading ? ( + + {[0, 1, 2].map((i) => ( + + + + + + + ))} + + ) : !hasLoaded ? ( + + {idleMessage} + + ) : showGroupSkeleton ? ( + + {[0, 1, 2].map((i) => ( + + + + + + + ))} + + ) : visibleJobs.length === 0 ? ( + + {emptyMessage} + + ) : ( + + )} + + ); +} diff --git a/src/app/types/HeadlessSessionsWidgetProps.ts b/src/app/types/HeadlessSessionsWidgetProps.ts new file mode 100644 index 0000000..fe85d4d --- /dev/null +++ b/src/app/types/HeadlessSessionsWidgetProps.ts @@ -0,0 +1,46 @@ +import type { Session } from '@/lib/api/skaha'; +import type { HeadlessJobGroup } from '@/lib/sessions/headlessJobs'; + +/** Allowed auto-refresh intervals (seconds) for the Headless Sessions widget. */ +export const HEADLESS_AUTO_REFRESH_INTERVALS_SEC = [ + 30, 45, 60, 120, 240, 480, 960, +] as const; + +export type HeadlessAutoRefreshIntervalSec = + (typeof HEADLESS_AUTO_REFRESH_INTERVALS_SEC)[number]; + +export interface HeadlessAutoRefreshSettings { + enabled: boolean; + intervalSec: HeadlessAutoRefreshIntervalSec; +} + +export interface HeadlessSessionsWidgetProps { + sessions?: Session[]; + /** Sessions with an in-flight mutation, keyed by session id. */ + operatingSessionIds?: Map; + /** Initial load — skeletons + indeterminate status bar (active groups). */ + isLoading?: boolean; + /** Background refetch — keep content, animate status bar. */ + isFetching?: boolean; + /** + * True after at least one successful/settled headless fetch. + * When false, the widget shows an idle prompt instead of an empty-group message. + */ + hasLoaded?: boolean; + /** Per-tab first-load in progress (progressive status-split fetch). */ + groupLoading?: Partial>; + /** Per-tab: every status query for that group has settled at least once. */ + groupLoaded?: Partial>; + /** Manual "Fetch now" (and DashboardWidget refresh if wired). */ + onRefresh?: () => void; + /** + * Notified when the user toggles auto-refresh or changes the interval. + * Parent should pass `refetchInterval` / `enabled` into `useHeadlessSessionsSplit`. + */ + onAutoRefreshSettingsChange?: (settings: HeadlessAutoRefreshSettings) => void; + title?: string; + showSessionCount?: boolean; + emptyMessage?: string; + /** Shown before the first fetch (auto-refresh off, no Fetch now yet). */ + idleMessage?: string; +} diff --git a/src/lib/api/skaha.ts b/src/lib/api/skaha.ts index 4ed32d7..2d033ec 100644 --- a/src/lib/api/skaha.ts +++ b/src/lib/api/skaha.ts @@ -24,7 +24,15 @@ export type SessionType = | 'firefly' | 'contributednotebook' | 'contributeddesktop'; -export type SessionStatus = 'Running' | 'Pending' | 'Terminating' | 'Error' | 'Failed' | 'Unknown'; +export type SessionStatus = + | 'Running' + | 'Pending' + | 'Terminating' + | 'Error' + | 'Failed' + | 'Succeeded' + | 'Completed' + | 'Unknown'; // SKAHA API raw response format export interface SkahaSessionResponse { @@ -206,6 +214,33 @@ export async function getSessions(): Promise { return skahaResponse.map(transformSkahaSession); } +/** + * Get headless (batch) sessions for the current user. + * Proxied as `GET /api/sessions?type=headless[&status=…]` → Skaha. + * Omit `status` for the full typed list; pass one Skaha status to filter. + */ +export async function getHeadlessSessions(options?: { + status?: SessionStatus | string; +}): Promise { + const authHeaders = getAuthHeader(); + const params = new URLSearchParams({ type: 'headless' }); + if (options?.status) { + params.set('status', options.status); + } + const response = await fetch(`${sessionsApiRoot()}?${params.toString()}`, { + method: 'GET', + headers: { Accept: 'application/json', ...authHeaders }, + credentials: 'include', + }); + + if (!response.ok) { + throw new Error(`Failed to fetch headless sessions: ${response.status}`); + } + + const skahaResponse: SkahaSessionResponse[] = await response.json(); + return skahaResponse.map(transformSkahaSession); +} + /** * Get details for a specific session */ diff --git a/src/lib/features/sessions/SessionsDashboard.tsx b/src/lib/features/sessions/SessionsDashboard.tsx index 4810084..977bdbb 100644 --- a/src/lib/features/sessions/SessionsDashboard.tsx +++ b/src/lib/features/sessions/SessionsDashboard.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useMemo, useCallback, useEffect } from 'react'; +import { useMemo, useCallback, useEffect, useState } from 'react'; import { ActiveSessionsWidget } from '@/app/components/ActiveSessionsWidget/ActiveSessionsWidget'; import { UserStorageWidget } from '@/app/components/UserStorageWidget/UserStorageWidget'; +import { HeadlessSessionsWidget } from '@/app/components/HeadlessSessionsWidget/HeadlessSessionsWidget'; import { LaunchFormWidget } from '@/app/components/LaunchFormWidget/LaunchFormWidget'; import { PlatformLoad } from '@/app/components/PlatformLoad/PlatformLoad'; import { Footer } from '@/app/components/Footer/Footer'; @@ -10,9 +11,10 @@ import { Box } from '@/app/components/Box/Box'; import { Container, Typography, useMediaQuery } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import type { SessionCardProps } from '@/app/types/SessionCardProps'; +import type { HeadlessAutoRefreshSettings } from '@/app/types/HeadlessSessionsWidgetProps'; import { useAuthStatus } from '@/lib/hooks/useAuth'; import { usePublicRuntimeConfig } from '@/lib/providers/PublicRuntimeConfigProvider'; -import { useSessions, useLaunchSession } from '@/lib/hooks/useSessions'; +import { useSessions, useHeadlessSessionsSplit, useLaunchSession } from '@/lib/hooks/useSessions'; import { useContainerImages, useImageRepositories, useContext } from '@/lib/hooks/useImages'; import { useUserStorageSummary } from '@/lib/hooks/useUserStorage'; import { STATIC_PLATFORM_LOAD_DATA } from '@/lib/config/static-platform-load'; @@ -27,6 +29,7 @@ import { } from '@/lib/config/site-config'; import { useOperatingSessionIds, useSessionUiActions } from '@/lib/stores'; import { SessionModalsHost } from '@/lib/features/sessions/SessionModalsHost'; +import { isInteractiveSession } from '@/lib/sessions/sessionQuota'; export function SessionsDashboard() { const theme = useTheme(); @@ -43,6 +46,16 @@ export function SessionsDashboard() { const operatingSessionIds = useOperatingSessionIds(); const { clearOperating } = useSessionUiActions(); + // Headless fetch is opt-in: off until "Fetch now" or auto-refresh is enabled. + const [headlessFetchEnabled, setHeadlessFetchEnabled] = useState(false); + const [headlessAutoRefresh, setHeadlessAutoRefresh] = useState({ + enabled: false, + intervalSec: 45, + }); + + const headlessQueryEnabled = + isAuthenticated && (headlessFetchEnabled || headlessAutoRefresh.enabled); + const { data: sessions = [], isLoading: isLoadingSessionsQuery, @@ -50,6 +63,21 @@ export function SessionsDashboard() { refetch: refetchSessions, } = useSessions(isAuthenticated); + const { + sessions: headlessSessions, + isLoading: isLoadingHeadlessQuery, + isFetching: isFetchingHeadless, + isFetched: isHeadlessFetched, + refetch: refetchHeadless, + groupLoading: headlessGroupLoading, + groupLoaded: headlessGroupLoaded, + } = useHeadlessSessionsSplit({ + enabled: headlessQueryEnabled, + refetchInterval: headlessAutoRefresh.enabled + ? headlessAutoRefresh.intervalSec * 1000 + : false, + }); + const { data: imagesByType = {}, isLoading: isLoadingImages, @@ -92,6 +120,8 @@ export function SessionsDashboard() { // isLoading = initial load, no data yet → widgets render skeletons. // isFetching = background refetch → widgets keep content, status bar animates. const isLoadingSessions = authLoading || (isAuthenticated && isLoadingSessionsQuery); + // Headless stays idle until the user fetches or enables auto-refresh. + const isLoadingHeadless = headlessQueryEnabled && isLoadingHeadlessQuery; const isLoadingLaunchForm = authLoading || @@ -101,45 +131,59 @@ export function SessionsDashboard() { const isLoadingUserStorage = authLoading || (isAuthenticated && isLoadingStorageSummary); - // A deleted session keeps its "Terminating" card state until the server - // confirms it's gone, i.e. the session no longer appears in the refetched - // list. (Renew marks are cleared by the mutation callbacks instead.) + // A deleted session keeps its "Terminating" state until the server confirms + // it's gone from either the interactive or headless list. useEffect(() => { operatingSessionIds.forEach((operation, sessionId) => { - if (operation === 'delete' && !sessions.some((s) => s.id === sessionId)) { + if (operation !== 'delete') return; + const stillInteractive = sessions.some((s) => s.id === sessionId); + const stillHeadless = headlessSessions.some((s) => s.id === sessionId); + if (!stillInteractive && !stillHeadless) { clearOperating(sessionId); } }); - }, [sessions, operatingSessionIds, clearOperating]); + }, [sessions, headlessSessions, operatingSessionIds, clearOperating]); const activeSessions: SessionCardProps[] = useMemo(() => { - return sessions - .filter( - (session: Session) => - session.sessionType !== 'headless' && session.sessionType !== 'desktop-app', - ) - .map((session: Session) => ({ - id: session.id, - sessionType: session.sessionType, - sessionName: session.sessionName, - status: session.status, - containerImage: session.containerImage, - startedTime: session.startedTime, - expiresTime: session.expiresTime, - memoryUsage: session.memoryUsage, - memoryAllocated: session.memoryAllocated, - cpuUsage: session.cpuUsage, - cpuAllocated: session.cpuAllocated, - gpuAllocated: session.gpuAllocated, - isFixedResources: session.isFixedResources, - connectUrl: session.connectUrl, - })); + return sessions.filter(isInteractiveSession).map((session: Session) => ({ + id: session.id, + sessionType: session.sessionType as SessionCardProps['sessionType'], + sessionName: session.sessionName, + status: session.status as SessionCardProps['status'], + containerImage: session.containerImage, + startedTime: session.startedTime, + expiresTime: session.expiresTime, + memoryUsage: session.memoryUsage, + memoryAllocated: session.memoryAllocated, + cpuUsage: session.cpuUsage, + cpuAllocated: session.cpuAllocated, + gpuAllocated: session.gpuAllocated, + isFixedResources: session.isFixedResources, + connectUrl: session.connectUrl, + })); }, [sessions]); const handleSessionsRefresh = useCallback(() => { refetchSessions(); }, [refetchSessions]); + const handleHeadlessRefresh = useCallback(() => { + if (!headlessQueryEnabled) { + // Enabling the query triggers the initial fetch on the next render. + setHeadlessFetchEnabled(true); + return; + } + void refetchHeadless(); + }, [headlessQueryEnabled, refetchHeadless]); + + const handleHeadlessAutoRefreshSettings = useCallback((settings: HeadlessAutoRefreshSettings) => { + setHeadlessAutoRefresh(settings); + // Turning auto-refresh on also opts into fetching (no separate Fetch now required). + if (settings.enabled) { + setHeadlessFetchEnabled(true); + } + }, []); + const handleStorageRefresh = useCallback(() => { void refetchStorage(); }, [refetchStorage]); @@ -259,6 +303,20 @@ export function SessionsDashboard() { + + + + [...sessionKeys.all, 'list'] as const, list: () => [...sessionKeys.lists()] as const, + headlessLists: () => [...sessionKeys.all, 'headless', 'list'] as const, + /** Legacy unfiltered headless list key (full `?type=headless`). */ + headlessList: () => [...sessionKeys.headlessLists()] as const, + headlessByStatus: (status: SessionStatus | string) => + [...sessionKeys.headlessLists(), status] as const, details: () => [...sessionKeys.all, 'detail'] as const, detail: (id: string) => [...sessionKeys.details(), id] as const, logs: (id: string) => [...sessionKeys.all, 'logs', id] as const, @@ -98,6 +113,173 @@ export function useSessions( }); } +/** + * Headless (batch) sessions — single unfiltered query (legacy / tests). + * Prefer `useHeadlessSessionsSplit` for the dashboard widget. + */ +export function useHeadlessSessions( + isAuthenticated?: boolean, + options?: Omit, 'queryKey' | 'queryFn'>, +) { + return useQuery({ + queryKey: sessionKeys.headlessList(), + queryFn: () => getHeadlessSessions(), + enabled: isAuthenticated !== false, + retry(failureCount, error) { + const msg = error instanceof Error ? error.message : String(error); + if (/\b401\b/.test(msg)) { + return false; + } + return failureCount < 3; + }, + refetchIntervalInBackground: false, + ...options, + }); +} + +export interface UseHeadlessSessionsSplitOptions { + /** When false, no status queries run (opt-in Fetch now / auto-refresh). */ + enabled?: boolean; + /** Polling interval in ms; false/undefined disables. Applied to enabled status queries. */ + refetchInterval?: number | false; +} + +export interface HeadlessSessionsSplitResult { + sessions: Session[]; + isLoading: boolean; + isFetching: boolean; + /** True after at least one active-status query has settled. */ + isFetched: boolean; + refetch: () => Promise; + /** Per-tab: true while that group's status queries are still on first load. */ + groupLoading: Record; + /** Per-tab: true once every status query for that group has settled at least once. */ + groupLoaded: Record; +} + +function headlessRetry(failureCount: number, error: Error) { + const msg = error.message; + if (/\b401\b/.test(msg)) { + return false; + } + return failureCount < 3; +} + +/** + * Headless sessions split by Skaha `status` — active statuses first, then terminal. + * Progressive: Pending/Running tabs fill before Completed/Failed requests start. + */ +export function useHeadlessSessionsSplit( + options: UseHeadlessSessionsSplitOptions = {}, +): HeadlessSessionsSplitResult { + const { enabled = false, refetchInterval = false } = options; + + const activeQueries = useQueries({ + queries: HEADLESS_ACTIVE_STATUSES.map((status) => ({ + queryKey: sessionKeys.headlessByStatus(status), + queryFn: () => getHeadlessSessions({ status }), + enabled, + retry: headlessRetry, + refetchInterval: enabled ? refetchInterval : false, + refetchIntervalInBackground: false, + })), + }); + + const activeSettled = + enabled && activeQueries.length > 0 && activeQueries.every((q) => q.isFetched); + + const terminalQueries = useQueries({ + queries: HEADLESS_TERMINAL_STATUSES.map((status) => ({ + queryKey: sessionKeys.headlessByStatus(status), + queryFn: () => getHeadlessSessions({ status }), + enabled: enabled && activeSettled, + retry: headlessRetry, + refetchInterval: enabled && activeSettled ? refetchInterval : false, + refetchIntervalInBackground: false, + })), + }); + + const allQueries = useMemo( + () => [...activeQueries, ...terminalQueries], + [activeQueries, terminalQueries], + ); + + const sessions = useMemo(() => { + const byId = new Map(); + for (const q of allQueries) { + for (const s of q.data ?? []) { + byId.set(s.id, s); + } + } + return Array.from(byId.values()); + }, [allQueries]); + + const lookupStatusQuery = useCallback( + (status: SessionStatus) => { + const activeIdx = HEADLESS_ACTIVE_STATUSES.indexOf(status); + if (activeIdx >= 0) return activeQueries[activeIdx]; + const terminalIdx = HEADLESS_TERMINAL_STATUSES.indexOf(status); + if (terminalIdx >= 0) return terminalQueries[terminalIdx]; + return undefined; + }, + [activeQueries, terminalQueries], + ); + + const groupLoaded = useMemo(() => { + const loaded = (group: HeadlessJobGroup) => + Boolean(enabled) && + headlessStatusesForGroup(group).every((status) => lookupStatusQuery(status)?.isFetched === true); + + return { + pending: loaded('pending'), + running: loaded('running'), + completed: loaded('completed'), + failed: loaded('failed'), + } satisfies Record; + }, [enabled, lookupStatusQuery]); + + const groupLoading = useMemo(() => { + const loading = (group: HeadlessJobGroup) => { + if (!enabled) return false; + const statuses = headlessStatusesForGroup(group); + const isTerminal = group === 'completed' || group === 'failed'; + if (isTerminal && !activeSettled) return true; + return statuses.some((status) => { + const q = lookupStatusQuery(status); + return Boolean(q && !q.isFetched && (q.isLoading || q.isPending || q.isFetching)); + }); + }; + return { + pending: loading('pending'), + running: loading('running'), + completed: loading('completed'), + failed: loading('failed'), + } satisfies Record; + }, [enabled, activeSettled, lookupStatusQuery]); + + const isFetched = Boolean(enabled && activeQueries.some((q) => q.isFetched)); + const isLoading = + Boolean(enabled) && !isFetched && activeQueries.some((q) => q.isLoading || q.isPending); + const isFetching = allQueries.some((q) => q.isFetching); + + const refetch = useCallback(async () => { + await Promise.all(activeQueries.map((q) => q.refetch())); + if (activeSettled) { + await Promise.all(terminalQueries.map((q) => q.refetch())); + } + }, [activeQueries, terminalQueries, activeSettled]); + + return { + sessions, + isLoading, + isFetching, + isFetched, + refetch, + groupLoading, + groupLoaded, + }; +} + /** * Get a specific session by ID * @@ -251,6 +433,7 @@ export function useDeleteSession(options?: UseMutationOptions ({ + id, + sessionType: 'headless', + sessionName: `batch-${id}`, + status, + containerImage: 'images.canfar.net/test/headless:latest', + startedTime: '', + expiresTime: '', + memoryAllocated: '', + cpuAllocated: '', +}); + +describe('isHeadlessSession', () => { + it('matches headless type only', () => { + expect(isHeadlessSession(headless('1', 'Running'))).toBe(true); + expect( + isHeadlessSession({ + ...headless('2', 'Running'), + sessionType: 'notebook', + }), + ).toBe(false); + }); +}); + +describe('groupHeadlessJobsByState', () => { + it('counts Pending correctly', () => { + const sessions = [ + headless('1', 'Pending'), + headless('2', 'Pending'), + headless('3', 'Running'), + ]; + expect(groupHeadlessJobsByState(sessions).pending).toBe(2); + }); + + it('counts Running correctly', () => { + const sessions = [ + headless('1', 'Running'), + headless('2', 'Running'), + headless('3', 'Running'), + headless('4', 'Pending'), + ]; + expect(groupHeadlessJobsByState(sessions).running).toBe(3); + }); + + it('maps Succeeded and Completed to completed', () => { + const sessions = [headless('1', 'Succeeded'), headless('2', 'Completed')]; + expect(groupHeadlessJobsByState(sessions).completed).toBe(2); + }); + + it('maps Failed and Error to failed', () => { + const sessions = [headless('1', 'Failed'), headless('2', 'Error')]; + expect(groupHeadlessJobsByState(sessions).failed).toBe(2); + }); + + it('treats Terminating as running', () => { + const sessions = [headless('1', 'Terminating'), headless('2', 'Running')]; + expect(groupHeadlessJobsByState(sessions).running).toBe(2); + }); + + it('ignores unknown statuses', () => { + const sessions = [headless('1', 'Unknown'), headless('2', 'Pending')]; + const result = groupHeadlessJobsByState(sessions); + expect(result.pending).toBe(1); + expect(result.running + result.completed + result.failed).toBe(0); + }); +}); + +describe('filterHeadlessJobsByGroup', () => { + const sessions = [ + headless('1', 'Pending'), + headless('2', 'Running'), + headless('3', 'Succeeded'), + headless('4', 'Failed'), + headless('5', 'Terminating'), + ]; + + it('filters each group', () => { + expect(filterHeadlessJobsByGroup(sessions, 'pending').map((s) => s.id)).toEqual(['1']); + expect(filterHeadlessJobsByGroup(sessions, 'running').map((s) => s.id)).toEqual([ + '2', + '5', + ]); + expect(filterHeadlessJobsByGroup(sessions, 'completed').map((s) => s.id)).toEqual(['3']); + expect(filterHeadlessJobsByGroup(sessions, 'failed').map((s) => s.id)).toEqual(['4']); + }); +}); + +describe('status-split mapping', () => { + it('maps each Skaha status to a widget group', () => { + expect(headlessGroupForStatus('Pending')).toBe('pending'); + expect(headlessGroupForStatus('Running')).toBe('running'); + expect(headlessGroupForStatus('Terminating')).toBe('running'); + expect(headlessGroupForStatus('Succeeded')).toBe('completed'); + expect(headlessGroupForStatus('Completed')).toBe('completed'); + expect(headlessGroupForStatus('Failed')).toBe('failed'); + expect(headlessGroupForStatus('Error')).toBe('failed'); + expect(headlessGroupForStatus('Unknown')).toBeNull(); + }); + + it('lists statuses per group for split fetches', () => { + expect(headlessStatusesForGroup('pending')).toEqual(['Pending']); + expect(headlessStatusesForGroup('running')).toEqual(['Running', 'Terminating']); + expect(headlessStatusesForGroup('completed')).toEqual(['Succeeded', 'Completed']); + expect(headlessStatusesForGroup('failed')).toEqual(['Failed', 'Error']); + }); + + it('orders active before terminal for progressive fetch', () => { + expect([...HEADLESS_ACTIVE_STATUSES]).toEqual(['Pending', 'Running', 'Terminating']); + expect([...HEADLESS_TERMINAL_STATUSES]).toEqual([ + 'Succeeded', + 'Completed', + 'Failed', + 'Error', + ]); + expect([...HEADLESS_SPLIT_STATUSES]).toEqual([ + ...HEADLESS_ACTIVE_STATUSES, + ...HEADLESS_TERMINAL_STATUSES, + ]); + }); +}); diff --git a/src/lib/sessions/headlessJobs.ts b/src/lib/sessions/headlessJobs.ts new file mode 100644 index 0000000..059c2f5 --- /dev/null +++ b/src/lib/sessions/headlessJobs.ts @@ -0,0 +1,134 @@ +import type { Session, SessionStatus } from '@/lib/api/skaha'; + +/** Status tabs in the Headless Sessions widget (CanfarDesktop BatchJobs parity). */ +export type HeadlessJobGroup = 'pending' | 'running' | 'completed' | 'failed'; + +export interface HeadlessJobCounts { + pending: number; + running: number; + completed: number; + failed: number; +} + +/** Skaha statuses fetched first (Pending / Running tabs). */ +export const HEADLESS_ACTIVE_STATUSES: readonly SessionStatus[] = [ + 'Pending', + 'Running', + 'Terminating', +]; + +/** Skaha statuses fetched after active settle (Completed / Failed tabs). */ +export const HEADLESS_TERMINAL_STATUSES: readonly SessionStatus[] = [ + 'Succeeded', + 'Completed', + 'Failed', + 'Error', +]; + +export const HEADLESS_SPLIT_STATUSES: readonly SessionStatus[] = [ + ...HEADLESS_ACTIVE_STATUSES, + ...HEADLESS_TERMINAL_STATUSES, +]; + +/** Which widget tab a Skaha status belongs to. */ +export function headlessGroupForStatus(status: SessionStatus | string): HeadlessJobGroup | null { + switch (status) { + case 'Pending': + return 'pending'; + case 'Running': + case 'Terminating': + return 'running'; + case 'Succeeded': + case 'Completed': + return 'completed'; + case 'Failed': + case 'Error': + return 'failed'; + default: + return null; + } +} + +/** Skaha status values that make up a widget tab. */ +export function headlessStatusesForGroup(group: HeadlessJobGroup): readonly SessionStatus[] { + switch (group) { + case 'pending': + return ['Pending']; + case 'running': + return ['Running', 'Terminating']; + case 'completed': + return ['Succeeded', 'Completed']; + case 'failed': + return ['Failed', 'Error']; + } +} + +export function isHeadlessSession(session: Session): boolean { + return session.sessionType === 'headless'; +} + +export function isHeadlessCompletedStatus(status: SessionStatus | string): boolean { + return status === 'Succeeded' || status === 'Completed'; +} + +export function isHeadlessFailedStatus(status: SessionStatus | string): boolean { + return status === 'Failed' || status === 'Error'; +} + +export function isHeadlessActiveStatus(status: SessionStatus | string): boolean { + return status === 'Pending' || status === 'Running' || status === 'Terminating'; +} + +/** + * Group headless jobs by lifecycle state — mirrors CanfarDesktop BatchJobsHelper. + */ +export function groupHeadlessJobsByState(sessions: Session[]): HeadlessJobCounts { + let pending = 0; + let running = 0; + let completed = 0; + let failed = 0; + + for (const s of sessions) { + switch (s.status) { + case 'Pending': + pending++; + break; + case 'Running': + case 'Terminating': + running++; + break; + case 'Succeeded': + case 'Completed': + completed++; + break; + case 'Failed': + case 'Error': + failed++; + break; + default: + break; + } + } + + return { pending, running, completed, failed }; +} + +export function filterHeadlessJobsByGroup( + sessions: Session[], + group: HeadlessJobGroup, +): Session[] { + return sessions.filter((s) => { + switch (group) { + case 'pending': + return s.status === 'Pending'; + case 'running': + return s.status === 'Running' || s.status === 'Terminating'; + case 'completed': + return isHeadlessCompletedStatus(s.status); + case 'failed': + return isHeadlessFailedStatus(s.status); + default: + return false; + } + }); +}