Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions web/packages/common/src/components/AccordionPanel/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import {
PanelContent,
PanelHeader,
PanelHeading,
PanelIcon,
PanelRoot,
} from '@nvidia/foundations-react-core';
import { ChevronDown, ChevronUp } from 'lucide-react';
import {
type ComponentProps,
type FC,
type KeyboardEvent,
type PropsWithChildren,
type ReactNode,
useState,
} from 'react';

export interface AccordionPanelProps {
/** Header text/content — rendered with the same typography as a Panel heading. */
slotHeading: ReactNode;
/** Optional leading icon in the header. */
slotIcon?: ReactNode;
/** Whether the panel starts expanded. Defaults to collapsed. */
defaultOpen?: boolean;
elevation?: ComponentProps<typeof PanelRoot>['elevation'];
density?: ComponentProps<typeof PanelRoot>['density'];
className?: string;
contentClassName?: string;
}

/**
* A Panel that collapses. Composes the Foundations Panel parts so it is visually
* identical to a `<Panel>` (border, radius, elevation, heading font), but the
* header is a toggle with a chevron and the body collapses. Collapsed content is
* unmounted, so any data fetching inside stops until it's expanded.
*/
export const AccordionPanel: FC<PropsWithChildren<AccordionPanelProps>> = ({
slotHeading,
slotIcon,
defaultOpen = false,
elevation = 'high',
density = 'compact',
className,
contentClassName,
children,
}) => {
const [open, setOpen] = useState(defaultOpen);
const toggle = () => setOpen((prev) => !prev);
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
};

return (
<PanelRoot elevation={elevation} density={density} className={className}>
<PanelHeader
role="button"
tabIndex={0}
aria-expanded={open}
onClick={toggle}
onKeyDown={onKeyDown}
className="cursor-pointer"
>
{slotIcon && <PanelIcon>{slotIcon}</PanelIcon>}
<PanelHeading>{slotHeading}</PanelHeading>
<PanelIcon className="ml-auto">{open ? <ChevronUp /> : <ChevronDown />}</PanelIcon>
</PanelHeader>
{open && <PanelContent className={contentClassName}>{children}</PanelContent>}
</PanelRoot>
);
};
15 changes: 15 additions & 0 deletions web/packages/common/src/hooks/useJobLogs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,17 @@ import {
type QueryObserverResult,
type RefetchOptions,
} from '@tanstack/react-query';
import { useEffect } from 'react';

import { LOGS_MAX_FETCH_ITERATIONS, LOGS_MAX_PAGES, LOGS_PAGE_SIZE } from '../../constants';
import { CJobTerminalStatuses } from '../../constants/query';
import { getJobRefetchInterval } from '../../utils/query';

// After a job goes terminal, refetchInterval stops polling — but OTLP log
// shipping can still be in flight, so the final lines would be lost. Refetch a
// few times post-terminal to capture the tail. Bounded and self-clearing.
const LOG_SETTLE_DELAYS_MS = [2_000, 6_000, 12_000];

export interface UseJobLogsOptions {
workspace: string;
name: string;
Expand Down Expand Up @@ -103,6 +110,14 @@ export const useJobLogs = ({
refetchInterval: () => getJobRefetchInterval(jobStatus),
});

const isTerminal = !!jobStatus && CJobTerminalStatuses.includes(jobStatus);
const { refetch } = query;
useEffect(() => {
if (!isTerminal) return;
const timers = LOG_SETTLE_DELAYS_MS.map((ms) => setTimeout(() => void refetch(), ms));
return () => timers.forEach(clearTimeout);
}, [isTerminal, refetch]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

return {
data: query.data?.logs ?? [],
isLoading: query.isLoading,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,25 @@

import { LogViewer } from '@nemo/common/src/components/LogViewer';
import { useJobLogs } from '@nemo/common/src/hooks/useJobLogs';
import type { PlatformJobStatus } from '@nemo/sdk/generated/platform/schema';
import { FC } from 'react';

interface StatusLogsContentProps {
workspace: string;
jobName: string;
/** When provided, logs poll while the job runs and stop once it's terminal. */
jobStatus?: PlatformJobStatus;
}

export const StatusLogsContent: FC<StatusLogsContentProps> = ({ workspace, jobName }) => {
export const StatusLogsContent: FC<StatusLogsContentProps> = ({
workspace,
jobName,
jobStatus,
}) => {
const { data: logs, isLoading } = useJobLogs({
workspace,
name: jobName,
jobStatus,
enabled: !!jobName,
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { AccordionPanel } from '@nemo/common/src/components/AccordionPanel';
import { ErrorMessage } from '@nemo/common/src/components/ErrorMessage';
import { KVPair } from '@nemo/common/src/components/KVPair';
import { RelativeTime } from '@nemo/common/src/components/RelativeTime';
import { StatusBadge } from '@nemo/common/src/components/StatusBadge';
import { useToast } from '@nemo/common/src/providers/toast/useToast';
import type { PlatformJobStatus } from '@nemo/sdk/generated/platform/schema';
import {
Badge,
Block,
Expand All @@ -19,6 +21,7 @@ import {
Text,
} from '@nvidia/foundations-react-core';
import { AccessibleTitle } from '@studio/components/AccessibleTitle';
import { StatusLogsContent } from '@studio/components/evaluation/Jobs/StatusLogsContent';
import { ROUTE_PARAMS } from '@studio/constants/routes';
import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath';
import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs';
Expand All @@ -38,7 +41,7 @@ import { fetchEvalAverageScores } from '@studio/routes/agents/AgentSuggestionsRo
import { getAgentEvaluationsListRoute, getAgentsListRoute } from '@studio/routes/utils';
import { useRequiredPathParams } from '@studio/util/hooks/useRequiredPathParams';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ClipboardList, FlaskConical, FolderOpen } from 'lucide-react';
import { ClipboardList, FlaskConical, FolderOpen, ScrollText } from 'lucide-react';
import { type FC } from 'react';

const TERMINAL_STATUSES = new Set([
Expand Down Expand Up @@ -289,6 +292,14 @@ export const AgentEvaluationDetailRoute: FC = () => {
<WorkflowOutputPanel items={workflowOutput} evaluatorOutputs={evaluatorOutputs ?? []} />
)}

<AccordionPanel slotHeading="Logs" slotIcon={<ScrollText />}>
<StatusLogsContent
workspace={workspace}
jobName={jobName}
jobStatus={job.status as PlatformJobStatus}
/>
</AccordionPanel>

{isJobTerminal && !isLoadingConfigFiles && (configFiles ?? []).length > 0 && (
<EvalConfigFilesPanel files={configFiles!} />
)}
Expand Down