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 }}
+ />
+
+
+
+
+
+ }
+ onClick={() => onRefresh?.()}
+ disabled={isBusy || !onRefresh}
+ >
+ Fetch now
+
+
+
+
+ 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;
+ }
+ });
+}