diff --git a/airbyte-webapp/src/area/connection/components/AttemptDetails/AttemptDetails.tsx b/airbyte-webapp/src/area/connection/components/AttemptDetails/AttemptDetails.tsx index 6441fa4fde8..7b8cebb9c90 100644 --- a/airbyte-webapp/src/area/connection/components/AttemptDetails/AttemptDetails.tsx +++ b/airbyte-webapp/src/area/connection/components/AttemptDetails/AttemptDetails.tsx @@ -4,7 +4,8 @@ import { FormattedDate, FormattedMessage, useIntl } from "react-intl"; import { FlexContainer } from "components/ui/Flex"; import { Text } from "components/ui/Text"; -import { AttemptRead, AttemptStats, AttemptStatus, FailureReason, FailureType } from "core/api/types/AirbyteClient"; +import { useAttemptCombinedStatsForJob } from "core/api"; +import { AttemptRead, AttemptStatus, FailureReason, FailureType } from "core/api/types/AirbyteClient"; import { formatBytes } from "core/utils/numberHelper"; import { useFormatLengthOfTime } from "core/utils/time"; @@ -19,11 +20,10 @@ interface AttemptDetailsProps { className?: string; attempt: AttemptRead; hasMultipleAttempts?: boolean; - jobId: string; + jobId: number; isPartialSuccess?: boolean; showEndedAt?: boolean; showFailureMessage?: boolean; - aggregatedAttemptStats?: AttemptStats; } export const AttemptDetails: React.FC = ({ @@ -33,8 +33,8 @@ export const AttemptDetails: React.FC = ({ isPartialSuccess, showEndedAt = false, showFailureMessage = true, - aggregatedAttemptStats, }) => { + const { data: aggregatedAttemptStats } = useAttemptCombinedStatsForJob(jobId, attempt); const { formatMessage } = useIntl(); const attemptRunTime = useFormatLengthOfTime((attempt.updatedAt - attempt.createdAt) * 1000); diff --git a/airbyte-webapp/src/area/connection/components/HistoricalOverview/ChartConfig.tsx b/airbyte-webapp/src/area/connection/components/HistoricalOverview/ChartConfig.tsx index c70f0e63635..7f08d87d666 100644 --- a/airbyte-webapp/src/area/connection/components/HistoricalOverview/ChartConfig.tsx +++ b/airbyte-webapp/src/area/connection/components/HistoricalOverview/ChartConfig.tsx @@ -129,8 +129,7 @@ export const ClickToJob = (chartState: CategoricalChartState & { height: number openJobLogsModal({ openModal, jobId, - connectionId: connection.connectionId, - connectionName: connection.name, + connection, }); return ( diff --git a/airbyte-webapp/src/area/connection/components/JobHistoryItem/VirtualLogs.tsx b/airbyte-webapp/src/area/connection/components/JobHistoryItem/VirtualLogs.tsx index b0f02827527..9f8ed28cc80 100644 --- a/airbyte-webapp/src/area/connection/components/JobHistoryItem/VirtualLogs.tsx +++ b/airbyte-webapp/src/area/connection/components/JobHistoryItem/VirtualLogs.tsx @@ -15,7 +15,7 @@ interface VirtualLogsProps { logLines: CleanedLogLines; searchTerm?: string; scrollTo?: number; - selectedAttempt?: number; + attemptId: number; hasFailure: boolean; showStructuredLogs: boolean; } @@ -57,7 +57,7 @@ const VirtualLogsUnmemoized: React.FC = ({ logLines, searchTerm, scrollTo, - selectedAttempt, + attemptId, hasFailure, showStructuredLogs, }) => { @@ -88,7 +88,7 @@ const VirtualLogsUnmemoized: React.FC = ({ // scroll, which results in not positioning at the bottom (isAtBottom) => isAtBottom && (hasFailure ? true : "smooth") } - key={selectedAttempt} + key={attemptId} style={{ width: "100%", height: "100%" }} data={logLines} itemContent={Row} @@ -245,6 +245,6 @@ export const VirtualLogs = React.memo( prevProps.logLines.length === nextProps.logLines.length && prevProps.searchTerm === nextProps.searchTerm && prevProps.scrollTo === nextProps.scrollTo && - prevProps.selectedAttempt === nextProps.selectedAttempt && + prevProps.attemptId === nextProps.attemptId && prevProps.showStructuredLogs === nextProps.showStructuredLogs ); diff --git a/airbyte-webapp/src/area/connection/components/JobLogsModal/AttemptLogs.tsx b/airbyte-webapp/src/area/connection/components/JobLogsModal/AttemptLogs.tsx new file mode 100644 index 00000000000..2ae20b26682 --- /dev/null +++ b/airbyte-webapp/src/area/connection/components/JobLogsModal/AttemptLogs.tsx @@ -0,0 +1,250 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useDebounce } from "react-use"; + +import { Box } from "components/ui/Box"; +import { FlexContainer, FlexItem } from "components/ui/Flex"; +import { MultiListBox } from "components/ui/ListBox/MultiListBox"; +import { Switch } from "components/ui/Switch"; +import { Text } from "components/ui/Text"; + +import { LogSearchInput } from "area/connection/components/JobHistoryItem/LogSearchInput"; +import { LOG_LEVELS, LOG_SOURCE_REGEX_MAP, useCleanLogs } from "area/connection/components/JobHistoryItem/useCleanLogs"; +import { VirtualLogs } from "area/connection/components/JobHistoryItem/VirtualLogs"; +import { attemptHasStructuredLogs, AttemptInfoReadWithLogs } from "core/api"; +import { LogLevel, LogSource } from "core/api/types/AirbyteClient"; + +import { JobLogsModalFailureMessage } from "./JobLogsModalFailureMessage"; + +interface AttemptLogsProps { + attempt: AttemptInfoReadWithLogs; +} + +export const AttemptLogs: React.FC = ({ attempt }) => { + const searchInputRef = useRef(null); + + const [inputValue, setInputValue] = useState(""); + const [highlightedMatchIndex, setHighlightedMatchIndex] = useState(undefined); + const [matchingLines, setMatchingLines] = useState([]); + const highlightedMatchingLineNumber = highlightedMatchIndex !== undefined ? highlightedMatchIndex + 1 : undefined; + + const showStructuredLogs = attempt && attemptHasStructuredLogs(attempt); + + const { logLines, sources, levels } = useCleanLogs(attempt); + const [selectedLogLevels, setSelectedLogLevels] = useState(LOG_LEVELS); + const [selectedLogSources, setSelectedLogSources] = useState(LOG_SOURCE_REGEX_MAP.map(({ key }) => key)); + const firstMatchIndex = 0; + const lastMatchIndex = matchingLines.length - 1; + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + const scrollTo = useMemo( + () => (matchingLines && highlightedMatchIndex !== undefined ? matchingLines[highlightedMatchIndex] : undefined), + [matchingLines, highlightedMatchIndex] + ); + const { formatMessage } = useIntl(); + + const logLevelOptions = useMemo>( + () => + LOG_LEVELS.map((level) => { + return { label: formatMessage({ id: `jobHistory.logs.logLevel.${level}` }), value: level }; + }), + [formatMessage] + ); + + const logSourceOptions = useMemo>( + () => + LOG_SOURCE_REGEX_MAP.map(({ key }) => { + return { label: formatMessage({ id: `jobHistory.logs.logSource.${key}` }), value: key }; + }), + [formatMessage] + ); + + const onSelectLogSource = useCallback( + (source: LogSource) => { + if (!selectedLogSources) { + setSelectedLogSources(sources.filter((s) => s !== source)); + } else { + setSelectedLogSources( + selectedLogSources.includes(source) + ? selectedLogSources.filter((s) => s !== source) + : [...selectedLogSources, source] + ); + } + }, + [sources, selectedLogSources] + ); + + const filteredLogLines = useMemo(() => { + return logLines.filter((line) => { + if (line.source && !selectedLogSources?.includes(line.source)) { + return false; + } + if (line.level && !selectedLogLevels?.includes(line.level)) { + return false; + } + return true; + }); + }, [logLines, selectedLogSources, selectedLogLevels]); + + // Debounces changes to the search input so we don't recompute the matching lines on every keystroke + useDebounce( + () => { + setDebouncedSearchTerm(inputValue); + setHighlightedMatchIndex(undefined); + const searchTermLowerCase = inputValue.toLowerCase(); + if (inputValue.length > 0) { + const matchingLines: number[] = []; + filteredLogLines.forEach((line, index) => { + return line.original.toLocaleLowerCase().includes(searchTermLowerCase) && matchingLines.push(index); + }); + setMatchingLines(matchingLines); + if (matchingLines.length > 0) { + setHighlightedMatchIndex(firstMatchIndex); + } else { + setHighlightedMatchIndex(undefined); + } + } else { + setMatchingLines([]); + setHighlightedMatchIndex(undefined); + } + }, + 150, + [inputValue, filteredLogLines] + ); + + const onSearchTermChange = (searchTerm: string) => { + setInputValue(searchTerm); + }; + + const onSearchInputKeydown = (e: React.KeyboardEvent) => { + if (e.shiftKey && e.key === "Enter") { + e.preventDefault(); + scrollToPreviousMatch(); + } else if (e.key === "Enter") { + e.preventDefault(); + scrollToNextMatch(); + } + }; + + const scrollToPreviousMatch = () => { + if (matchingLines.length === 0) { + return; + } + if (highlightedMatchIndex === undefined) { + setHighlightedMatchIndex(lastMatchIndex); + } else { + setHighlightedMatchIndex(highlightedMatchIndex === firstMatchIndex ? lastMatchIndex : highlightedMatchIndex - 1); + } + searchInputRef.current?.focus(); + }; + + const scrollToNextMatch = () => { + if (matchingLines.length === 0) { + return; + } + if (highlightedMatchIndex === undefined) { + setHighlightedMatchIndex(firstMatchIndex); + } else { + setHighlightedMatchIndex(highlightedMatchIndex === lastMatchIndex ? firstMatchIndex : highlightedMatchIndex + 1); + } + searchInputRef.current?.focus(); + }; + + // Focus the search input with cmd + f / ctrl + f + // Clear search input on `esc`, if search input is focused + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "f" && (navigator.platform.toLowerCase().includes("mac") ? e.metaKey : e.ctrlKey)) { + e.preventDefault(); + searchInputRef.current?.focus(); + } else if (e.key === "Escape" && document.activeElement === searchInputRef.current) { + if (inputValue.length > 0) { + e.preventDefault(); + setInputValue(""); + } + } + }; + document.body.addEventListener("keydown", handleKeyDown); + return () => document.body.removeEventListener("keydown", handleKeyDown); + }, [inputValue]); + + return ( + <> + + + + + + + {showStructuredLogs && ( + <> + + setSelectedLogSources(newSources ?? sources)} + label="Log sources" + /> + + + setSelectedLogLevels(newLevels ?? levels)} + label="Log levels" + /> + + + )} + + + + {sources.length > 0 && ( + + + {logSourceOptions.map((option) => ( + + ))} + + + )} + + {logLines.length === 0 && ( + + + + + + + + )} + + + + ); +}; diff --git a/airbyte-webapp/src/area/connection/components/JobLogsModal/DownloadLogsButton.tsx b/airbyte-webapp/src/area/connection/components/JobLogsModal/DownloadLogsButton.tsx index 61494883629..ae480b42700 100644 --- a/airbyte-webapp/src/area/connection/components/JobLogsModal/DownloadLogsButton.tsx +++ b/airbyte-webapp/src/area/connection/components/JobLogsModal/DownloadLogsButton.tsx @@ -4,31 +4,18 @@ import { FormattedMessage, useIntl } from "react-intl"; import { Button } from "components/ui/Button"; import { Tooltip } from "components/ui/Tooltip"; -import { CleanedLogLines } from "area/connection/components/JobHistoryItem/useCleanLogs"; -import { useCurrentWorkspace } from "core/api"; -import { downloadFile, FILE_TYPE_DOWNLOAD, fileizeString } from "core/utils/file"; - interface DownloadButtonProps { - logLines: CleanedLogLines; - fileName: string; + downloadLogs: () => void; } -export const DownloadLogsButton: React.FC = ({ logLines, fileName }) => { +export const DownloadLogsButton: React.FC = ({ downloadLogs }) => { const { formatMessage } = useIntl(); - const { name } = useCurrentWorkspace(); - - const downloadFileWithLogs = () => { - const file = new Blob([logLines.map((log) => log.original).join("\n")], { - type: FILE_TYPE_DOWNLOAD, - }); - downloadFile(file, fileizeString(`${name}-${fileName}.txt`)); - }; return ( = ({ jobId, initialAttemptId, eventId, connectionId }) => { +export const JobLogsModal: React.FC = ({ jobId, initialAttemptId, eventId, connection }) => { const job = useJobInfoWithoutLogs(jobId); if (job.attempts.length === 0) { @@ -50,46 +41,21 @@ export const JobLogsModal: React.FC = ({ jobId, initialAttemp } return ( - + ); }; -const JobLogsModalInner: React.FC = ({ jobId, initialAttemptId, eventId, connectionId }) => { - const searchInputRef = useRef(null); +const JobLogsModalInner: React.FC = ({ jobId, initialAttemptId, eventId, connection }) => { const job = useJobInfoWithoutLogs(jobId); - const [inputValue, setInputValue] = useState(""); - const [highlightedMatchIndex, setHighlightedMatchIndex] = useState(undefined); - const [matchingLines, setMatchingLines] = useState([]); - const highlightedMatchingLineNumber = highlightedMatchIndex !== undefined ? highlightedMatchIndex + 1 : undefined; - const [selectedAttemptId, setSelectedAttemptId] = useState( initialAttemptId ?? job.attempts[job.attempts.length - 1].attempt.id ); - const jobAttempt = useAttemptForJob(jobId, selectedAttemptId); - const showStructuredLogs = attemptHasStructuredLogs(jobAttempt); - const aggregatedAttemptStats = useAttemptCombinedStatsForJob(jobId, selectedAttemptId, { - refetchInterval() { - // if the attempt hasn't ended refetch every 2.5 seconds - return jobAttempt.attempt.endedAt ? false : 2500; - }, - }); - const { logLines, sources, levels } = useCleanLogs(jobAttempt); - const [selectedLogLevels, setSelectedLogLevels] = useState(LOG_LEVELS); - const [selectedLogOrigins, setSelectedLogOrigins] = useState(LOG_SOURCE_REGEX_MAP.map(({ key }) => key)); - const firstMatchIndex = 0; - const lastMatchIndex = matchingLines.length - 1; - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); - const scrollTo = useMemo( - () => (matchingLines && highlightedMatchIndex !== undefined ? matchingLines[highlightedMatchIndex] : undefined), - [matchingLines, highlightedMatchIndex] - ); + const { data: jobAttempt } = useAttemptForJob(jobId, selectedAttemptId); + + const downloadLogs = useDonwnloadJobLogsFetchQuery(); + const { formatMessage } = useIntl(); const attemptListboxOptions = useMemo(() => { @@ -103,140 +69,8 @@ const JobLogsModalInner: React.FC = ({ jobId, initialAttemptI })); }, [job, formatMessage]); - const onSelectAttempt = (selectedAttemptId: number) => { - setSelectedAttemptId(selectedAttemptId); - setHighlightedMatchIndex(undefined); - setMatchingLines([]); - setInputValue(""); - }; - - const logLevelOptions = useMemo>( - () => - LOG_LEVELS.map((level) => { - return { label: formatMessage({ id: `jobHistory.logs.logLevel.${level}` }), value: level }; - }), - [formatMessage] - ); - - const logOriginOptions = useMemo>( - () => - LOG_SOURCE_REGEX_MAP.map(({ key }) => { - return { label: formatMessage({ id: `jobHistory.logs.logSource.${key}` }), value: key }; - }), - [formatMessage] - ); - - const onSelectLogOrigin = useCallback( - (origin: LogSource) => { - if (!selectedLogOrigins) { - setSelectedLogOrigins(sources.filter((o) => o !== origin)); - } else { - setSelectedLogOrigins( - selectedLogOrigins.includes(origin) - ? selectedLogOrigins.filter((o) => o !== origin) - : [...selectedLogOrigins, origin] - ); - } - }, - [sources, selectedLogOrigins] - ); - - const filteredLogLines = useMemo(() => { - return logLines.filter((line) => { - if (line.source && !selectedLogOrigins?.includes(line.source)) { - return false; - } - if (line.level && !selectedLogLevels?.includes(line.level)) { - return false; - } - return true; - }); - }, [logLines, selectedLogOrigins, selectedLogLevels]); - - // Debounces changes to the search input so we don't recompute the matching lines on every keystroke - useDebounce( - () => { - setDebouncedSearchTerm(inputValue); - setHighlightedMatchIndex(undefined); - const searchTermLowerCase = inputValue.toLowerCase(); - if (inputValue.length > 0) { - const matchingLines: number[] = []; - filteredLogLines.forEach((line, index) => { - return line.original.toLocaleLowerCase().includes(searchTermLowerCase) && matchingLines.push(index); - }); - setMatchingLines(matchingLines); - if (matchingLines.length > 0) { - setHighlightedMatchIndex(firstMatchIndex); - } else { - setHighlightedMatchIndex(undefined); - } - } else { - setMatchingLines([]); - setHighlightedMatchIndex(undefined); - } - }, - 150, - [inputValue, filteredLogLines] - ); - - const onSearchTermChange = (searchTerm: string) => { - setInputValue(searchTerm); - }; - - const onSearchInputKeydown = (e: React.KeyboardEvent) => { - if (e.shiftKey && e.key === "Enter") { - e.preventDefault(); - scrollToPreviousMatch(); - } else if (e.key === "Enter") { - e.preventDefault(); - scrollToNextMatch(); - } - }; - - const scrollToPreviousMatch = () => { - if (matchingLines.length === 0) { - return; - } - if (highlightedMatchIndex === undefined) { - setHighlightedMatchIndex(lastMatchIndex); - } else { - setHighlightedMatchIndex(highlightedMatchIndex === firstMatchIndex ? lastMatchIndex : highlightedMatchIndex - 1); - } - searchInputRef.current?.focus(); - }; - - const scrollToNextMatch = () => { - if (matchingLines.length === 0) { - return; - } - if (highlightedMatchIndex === undefined) { - setHighlightedMatchIndex(firstMatchIndex); - } else { - setHighlightedMatchIndex(highlightedMatchIndex === lastMatchIndex ? firstMatchIndex : highlightedMatchIndex + 1); - } - searchInputRef.current?.focus(); - }; - - // Focus the search input with cmd + f / ctrl + f - // Clear search input on `esc`, if search input is focused - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "f" && (navigator.platform.toLowerCase().includes("mac") ? e.metaKey : e.ctrlKey)) { - e.preventDefault(); - searchInputRef.current?.focus(); - } else if (e.key === "Escape" && document.activeElement === searchInputRef.current) { - if (inputValue.length > 0) { - e.preventDefault(); - setInputValue(""); - } - } - }; - document.body.addEventListener("keydown", handleKeyDown); - return () => document.body.removeEventListener("keydown", handleKeyDown); - }, [inputValue]); - return ( - +
@@ -244,93 +78,37 @@ const JobLogsModalInner: React.FC = ({ jobId, initialAttemptI className={styles.attemptDropdown__listbox} selectedValue={selectedAttemptId} options={attemptListboxOptions} - onSelect={onSelectAttempt} + onSelect={setSelectedAttemptId} isDisabled={job.attempts.length === 1} />
- + {jobAttempt ? ( + + ) : ( + + + + )} - + downloadLogs(connection.name, jobId)} />
- - - - - - - {showStructuredLogs && ( - <> - - setSelectedLogOrigins(newOrigins ?? sources)} - label={formatMessage({ id: "jobHistory.logs.logSources" })} - /> - - - setSelectedLogLevels(newLevels ?? levels)} - label={formatMessage({ id: "jobHistory.logs.logLevels" })} - /> - - - )} - - - - {sources.length > 0 && ( - - - {logOriginOptions.map((option) => ( - - ))} - - + {jobAttempt && } + {!jobAttempt && ( +
+ + + + +
)} -
); }; diff --git a/airbyte-webapp/src/core/api/hooks/jobs.ts b/airbyte-webapp/src/core/api/hooks/jobs.ts index 532f5c86175..9e4debb42b4 100644 --- a/airbyte-webapp/src/core/api/hooks/jobs.ts +++ b/airbyte-webapp/src/core/api/hooks/jobs.ts @@ -1,5 +1,12 @@ -import { UseQueryOptions, useIsMutating, useMutation, useQuery } from "@tanstack/react-query"; +import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { useIntl } from "react-intl"; +import { trackError } from "core/utils/datadog"; +import { FILE_TYPE_DOWNLOAD, downloadFile, fileizeString } from "core/utils/file"; +import { useNotificationService } from "hooks/services/Notification"; + +import { useCurrentWorkspace } from "./workspaces"; import { cancelJob, getAttemptCombinedStats, @@ -8,7 +15,7 @@ import { getJobInfoWithoutLogs, } from "../generated/AirbyteClient"; import { SCOPE_WORKSPACE } from "../scopes"; -import { AttemptInfoRead, AttemptStats, LogEvents, LogRead } from "../types/AirbyteClient"; +import { AttemptInfoRead, AttemptRead, LogEvents, LogRead } from "../types/AirbyteClient"; import { useRequestOptions } from "../useRequestOptions"; import { useSuspenseQuery } from "../useSuspenseQuery"; @@ -16,14 +23,6 @@ export const jobsKeys = { all: (connectionId: string | undefined) => [SCOPE_WORKSPACE, connectionId] as const, }; -// A disabled useQuery that can be called manually to download job logs -export const useGetDebugInfoJobManual = (id: number) => { - const requestOptions = useRequestOptions(); - return useQuery([SCOPE_WORKSPACE, "jobs", "getDebugInfo", id], () => getJobDebugInfo({ id }, requestOptions), { - enabled: false, - }); -}; - export const useCancelJob = () => { const requestOptions = useRequestOptions(); const mutation = useMutation(["useCancelJob"], (id: number) => cancelJob({ id }, requestOptions)); @@ -54,7 +53,7 @@ export const useJobInfoWithoutLogs = (id: number) => { type AttemptInfoReadWithFormattedLogs = AttemptInfoRead & { logType: "formatted"; logs: LogRead }; type AttemptInfoReadWithStructuredLogs = AttemptInfoRead & { logType: "structured"; logs: LogEvents }; -type AttemptInfoReadWithLogs = AttemptInfoReadWithFormattedLogs | AttemptInfoReadWithStructuredLogs; +export type AttemptInfoReadWithLogs = AttemptInfoReadWithFormattedLogs | AttemptInfoReadWithStructuredLogs; export function attemptHasFormattedLogs(attempt: AttemptInfoRead): attempt is AttemptInfoReadWithFormattedLogs { return attempt.logType === "formatted"; @@ -66,7 +65,7 @@ export function attemptHasStructuredLogs(attempt: AttemptInfoRead): attempt is A export const useAttemptForJob = (jobId: number, attemptNumber: number) => { const requestOptions = useRequestOptions(); - return useSuspenseQuery( + return useQuery( [SCOPE_WORKSPACE, "jobs", "attemptForJob", jobId, attemptNumber], () => getAttemptForJob({ jobId, attemptNumber }, requestOptions) as Promise, { @@ -83,20 +82,96 @@ export const useAttemptForJob = (jobId: number, attemptNumber: number) => { ); }; -export const useAttemptCombinedStatsForJob = ( - jobId: number, - attemptNumber: number, - options?: Readonly, "queryKey" | "queryFn" | "suspense">> -) => { +export const useAttemptCombinedStatsForJob = (jobId: number, attempt: AttemptRead) => { + const requestOptions = useRequestOptions(); + return useQuery( + [SCOPE_WORKSPACE, "jobs", "attemptCombinedStatsForJob", jobId, attempt.id], + () => getAttemptCombinedStats({ jobId, attemptNumber: attempt.id }, requestOptions), + { + refetchInterval: () => { + // if the attempt hasn't ended refetch every 2.5 seconds + return attempt.endedAt ? false : 2500; + }, + } + ); +}; + +export const useDonwnloadJobLogsFetchQuery = () => { const requestOptions = useRequestOptions(); - // the endpoint returns a 404 if there aren't stats for this attempt - try { - return useSuspenseQuery( - [SCOPE_WORKSPACE, "jobs", "attemptCombinedStatsForJob", jobId, attemptNumber], - () => getAttemptCombinedStats({ jobId, attemptNumber }, requestOptions), - options - ); - } catch (e) { - return undefined; - } + const queryClient = useQueryClient(); + const { registerNotification, unregisterNotificationById } = useNotificationService(); + const workspace = useCurrentWorkspace(); + const { formatMessage } = useIntl(); + + return useCallback( + (connectionName: string, jobId: number) => { + // Promise.all() with a timeout is used to ensure that the notification is shown to the user for at least 1 second + queryClient.fetchQuery({ + queryKey: [SCOPE_WORKSPACE, "jobs", "getDebugInfo", jobId], + queryFn: async () => { + const notificationId = `download-logs-${jobId}`; + registerNotification({ + type: "info", + text: formatMessage( + { + id: "jobHistory.logs.logDownloadPending", + }, + { jobId } + ), + id: notificationId, + timeout: false, + }); + try { + return await Promise.all([ + getJobDebugInfo({ id: jobId }, requestOptions) + .then((data) => { + if (!data) { + throw new Error("No logs returned from server"); + } + const file = new Blob( + [ + data.attempts + .flatMap((info, index) => [ + `>> ATTEMPT ${index + 1}/${data.attempts.length}\n`, + ...(attemptHasFormattedLogs(info) ? info.logs.logLines : []), + ...(attemptHasStructuredLogs(info) + ? info.logs.events.map((event) => JSON.stringify(event)) + : []), + `\n\n\n`, + ]) + .join("\n"), + ], + { + type: FILE_TYPE_DOWNLOAD, + } + ); + downloadFile(file, fileizeString(`${connectionName}-logs-${jobId}.txt`)); + }) + .catch((e) => { + trackError(e, { workspaceId: workspace.workspaceId, jobId }); + registerNotification({ + type: "error", + text: formatMessage({ + id: "jobHistory.logs.logDownloadFailed", + }), + id: `download-logs-error-${jobId}`, + }); + }), + new Promise((resolve) => setTimeout(resolve, 1000)), + ]); + } finally { + unregisterNotificationById(notificationId); + } + }, + }); + }, + [ + formatMessage, + queryClient, + registerNotification, + requestOptions, + unregisterNotificationById, + workspace.workspaceId, + ] + ); }; diff --git a/airbyte-webapp/src/locales/en.json b/airbyte-webapp/src/locales/en.json index b81c5cbaaa2..9d67438ac42 100644 --- a/airbyte-webapp/src/locales/en.json +++ b/airbyte-webapp/src/locales/en.json @@ -1911,6 +1911,9 @@ "jobHistory.logs.title": "Logs: {connectionName}", "jobHistory.logs.noLogs": "No logs", + "jobHistory.logs.loadingJob": "Loading job...", + "jobHistory.logs.loadingAttempt": "Loading attempt...", + "jobHistory.logs.noLogsFound": "No logs found for this job.", "jobHistory.logs.noAttempts": "An unknown error prevented this job from generating any attempts. No logs are available.", "jobHistory.logs.nextMatchLabel": "Jump to next match", "jobHistory.logs.previousMatchLabel": "Jump to previous match", diff --git a/airbyte-webapp/src/pages/connections/ConnectionTimelinePage/ConnectionTimelinePage.tsx b/airbyte-webapp/src/pages/connections/ConnectionTimelinePage/ConnectionTimelinePage.tsx index aba144a99a2..1dd65af6b06 100644 --- a/airbyte-webapp/src/pages/connections/ConnectionTimelinePage/ConnectionTimelinePage.tsx +++ b/airbyte-webapp/src/pages/connections/ConnectionTimelinePage/ConnectionTimelinePage.tsx @@ -51,9 +51,8 @@ export const ConnectionTimelinePage = () => { openModal, jobId: !isNaN(jobIdFromFilter) ? jobIdFromFilter : undefined, eventId: filterValues.eventId, - connectionName: connection.name, + connection, attemptNumber: !isNaN(attemptNumberFromFilter) ? attemptNumberFromFilter : undefined, - connectionId: connection.connectionId, setFilterValue, }); } diff --git a/airbyte-webapp/src/pages/connections/ConnectionTimelinePage/JobEventMenu.module.scss b/airbyte-webapp/src/pages/connections/ConnectionTimelinePage/JobEventMenu.module.scss index fa1fac4558b..14dc8501802 100644 --- a/airbyte-webapp/src/pages/connections/ConnectionTimelinePage/JobEventMenu.module.scss +++ b/airbyte-webapp/src/pages/connections/ConnectionTimelinePage/JobEventMenu.module.scss @@ -1,12 +1,11 @@ +@use "scss/variables"; + .modalLoading { position: relative; flex-grow: 1; display: flex; justify-content: center; align-items: center; -} - -// fix for "dancing" scrollbar -.spinnerContainer { - overflow: clip; + flex-direction: column; + gap: variables.$spacing-lg; } diff --git a/airbyte-webapp/src/pages/connections/ConnectionTimelinePage/JobEventMenu.tsx b/airbyte-webapp/src/pages/connections/ConnectionTimelinePage/JobEventMenu.tsx index c9a7ba3a20d..e02e2bb3ace 100644 --- a/airbyte-webapp/src/pages/connections/ConnectionTimelinePage/JobEventMenu.tsx +++ b/airbyte-webapp/src/pages/connections/ConnectionTimelinePage/JobEventMenu.tsx @@ -3,22 +3,13 @@ import { FormattedMessage, useIntl } from "react-intl"; import { Button } from "components/ui/Button"; import { DropdownMenu, DropdownMenuOptionType } from "components/ui/DropdownMenu"; -import { FlexContainer } from "components/ui/Flex"; -import { LoadingSpinner } from "components/ui/LoadingSpinner"; import { Spinner } from "components/ui/Spinner"; +import { Text } from "components/ui/Text"; -import { formatLogEvent } from "area/connection/components/JobHistoryItem/useCleanLogs"; -import { - attemptHasFormattedLogs, - attemptHasStructuredLogs, - useCurrentConnection, - useCurrentWorkspace, - useGetDebugInfoJobManual, -} from "core/api"; +import { useCurrentConnection, useDonwnloadJobLogsFetchQuery } from "core/api"; +import { WebBackendConnectionRead } from "core/api/types/AirbyteClient"; import { DefaultErrorBoundary } from "core/errors"; import { copyToClipboard } from "core/utils/clipboard"; -import { trackError } from "core/utils/datadog"; -import { FILE_TYPE_DOWNLOAD, downloadFile, fileizeString } from "core/utils/file"; import { ModalOptions, ModalResult, useModalService } from "hooks/services/Modal"; import { useNotificationService } from "hooks/services/Notification"; @@ -36,17 +27,15 @@ export const openJobLogsModal = ({ openModal, jobId, eventId, - connectionName, + connection, attemptNumber, - connectionId, setFilterValue, }: { openModal: (options: ModalOptions) => Promise>; jobId?: number; eventId?: string; - connectionName: string; + connection: WebBackendConnectionRead; attemptNumber?: number; - connectionId: string; setFilterValue?: (filterName: keyof TimelineFilterValues, value: string) => void; }) => { if (!jobId && !eventId) { @@ -55,22 +44,20 @@ export const openJobLogsModal = ({ openModal({ size: "full", - title: , + title: , content: () => ( + + + } > - + ), @@ -89,10 +76,9 @@ export const JobEventMenu: React.FC<{ eventId?: string; jobId: number; attemptCo const { formatMessage } = useIntl(); const connection = useCurrentConnection(); const { openModal } = useModalService(); - const { registerNotification, unregisterNotificationById } = useNotificationService(); + const { registerNotification } = useNotificationService(); - const { refetch: fetchJobLogs } = useGetDebugInfoJobManual(jobId); - const { name: workspaceName, workspaceId } = useCurrentWorkspace(); + const downloadJobLogs = useDonwnloadJobLogsFetchQuery(); const onChangeHandler = (optionClicked: DropdownMenuOptionType) => { switch (optionClicked.value) { @@ -101,8 +87,7 @@ export const JobEventMenu: React.FC<{ eventId?: string; jobId: number; attemptCo openModal, jobId, eventId, - connectionName: connection.name, - connectionId: connection.connectionId, + connection, }); break; @@ -125,61 +110,7 @@ export const JobEventMenu: React.FC<{ eventId?: string; jobId: number; attemptCo } case JobMenuOptions.DownloadLogs: - const notificationId = `download-logs-${jobId}`; - registerNotification({ - type: "info", - text: ( - - -
- -
-
- ), - id: notificationId, - timeout: false, - }); - // Promise.all() with a timeout is used to ensure that the notification is shown to the user for at least 1 second - Promise.all([ - fetchJobLogs() - .then(({ data }) => { - if (!data) { - throw new Error("No logs returned from server"); - } - const file = new Blob( - [ - data.attempts - .flatMap((info, index) => [ - `>> ATTEMPT ${index + 1}/${data.attempts.length}\n`, - ...(attemptHasFormattedLogs(info) ? info.logs.logLines : []), - ...(attemptHasStructuredLogs(info) ? info.logs.events.map((event) => formatLogEvent(event)) : []), - `\n\n\n`, - ]) - .join("\n"), - ], - { - type: FILE_TYPE_DOWNLOAD, - } - ); - downloadFile(file, fileizeString(`${workspaceName}-logs-${jobId}.txt`)); - }) - .catch((e) => { - trackError(e, { workspaceId, jobId }); - registerNotification({ - type: "error", - text: formatMessage( - { - id: "jobHistory.logs.logDownloadFailed", - }, - { connectionName: connection.name } - ), - id: `download-logs-error-${jobId}`, - }); - }), - new Promise((resolve) => setTimeout(resolve, 1000)), - ]).finally(() => { - unregisterNotificationById(notificationId); - }); + downloadJobLogs(connection.name, jobId); break; } }; diff --git a/airbyte-webapp/src/pages/connections/ConnectionTimelinePage/JobLogsModalContent.tsx b/airbyte-webapp/src/pages/connections/ConnectionTimelinePage/JobLogsModalContent.tsx index f53866d918b..71ecbdbbc49 100644 --- a/airbyte-webapp/src/pages/connections/ConnectionTimelinePage/JobLogsModalContent.tsx +++ b/airbyte-webapp/src/pages/connections/ConnectionTimelinePage/JobLogsModalContent.tsx @@ -1,14 +1,15 @@ import { JobLogsModal } from "area/connection/components/JobLogsModal/JobLogsModal"; import { useGetConnectionEvent } from "core/api"; +import { WebBackendConnectionRead } from "core/api/types/AirbyteClient"; export const JobLogsModalContent: React.FC<{ eventId?: string; jobId?: number; attemptNumber?: number; resetFilters?: () => void; - connectionId: string; -}> = ({ eventId, jobId, attemptNumber, resetFilters, connectionId }) => { - const { data: singleEventItem } = useGetConnectionEvent(eventId ?? null, connectionId); + connection: WebBackendConnectionRead; +}> = ({ eventId, jobId, attemptNumber, resetFilters, connection }) => { + const { data: singleEventItem } = useGetConnectionEvent(eventId ?? null, connection.connectionId); const jobIdFromEvent = singleEventItem?.summary.jobId; @@ -21,7 +22,5 @@ export const JobLogsModalContent: React.FC<{ return null; } - return ( - - ); + return ; };