Skip to content

Commit

Permalink
design: improve job logs loading UX (#14638)
Browse files Browse the repository at this point in the history
  • Loading branch information
josephkmh committed Nov 27, 2024
1 parent 142e56f commit d300367
Show file tree
Hide file tree
Showing 13 changed files with 439 additions and 427 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<AttemptDetailsProps> = ({
Expand All @@ -33,8 +33,8 @@ export const AttemptDetails: React.FC<AttemptDetailsProps> = ({
isPartialSuccess,
showEndedAt = false,
showFailureMessage = true,
aggregatedAttemptStats,
}) => {
const { data: aggregatedAttemptStats } = useAttemptCombinedStatsForJob(jobId, attempt);
const { formatMessage } = useIntl();
const attemptRunTime = useFormatLengthOfTime((attempt.updatedAt - attempt.createdAt) * 1000);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,7 @@ export const ClickToJob = (chartState: CategoricalChartState & { height: number
openJobLogsModal({
openModal,
jobId,
connectionId: connection.connectionId,
connectionName: connection.name,
connection,
});

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface VirtualLogsProps {
logLines: CleanedLogLines;
searchTerm?: string;
scrollTo?: number;
selectedAttempt?: number;
attemptId: number;
hasFailure: boolean;
showStructuredLogs: boolean;
}
Expand Down Expand Up @@ -57,7 +57,7 @@ const VirtualLogsUnmemoized: React.FC<VirtualLogsProps> = ({
logLines,
searchTerm,
scrollTo,
selectedAttempt,
attemptId,
hasFailure,
showStructuredLogs,
}) => {
Expand Down Expand Up @@ -88,7 +88,7 @@ const VirtualLogsUnmemoized: React.FC<VirtualLogsProps> = ({
// 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}
Expand Down Expand Up @@ -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
);
Original file line number Diff line number Diff line change
@@ -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<AttemptLogsProps> = ({ attempt }) => {
const searchInputRef = useRef<HTMLInputElement>(null);

const [inputValue, setInputValue] = useState("");
const [highlightedMatchIndex, setHighlightedMatchIndex] = useState<number | undefined>(undefined);
const [matchingLines, setMatchingLines] = useState<number[]>([]);
const highlightedMatchingLineNumber = highlightedMatchIndex !== undefined ? highlightedMatchIndex + 1 : undefined;

const showStructuredLogs = attempt && attemptHasStructuredLogs(attempt);

const { logLines, sources, levels } = useCleanLogs(attempt);
const [selectedLogLevels, setSelectedLogLevels] = useState<LogLevel[]>(LOG_LEVELS);
const [selectedLogSources, setSelectedLogSources] = useState<LogSource[]>(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<Array<{ label: string; value: LogLevel }>>(
() =>
LOG_LEVELS.map((level) => {
return { label: formatMessage({ id: `jobHistory.logs.logLevel.${level}` }), value: level };
}),
[formatMessage]
);

const logSourceOptions = useMemo<Array<{ label: string; value: LogSource }>>(
() =>
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<HTMLInputElement>) => {
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 (
<>
<JobLogsModalFailureMessage failureSummary={attempt.attempt.failureSummary} />
<Box px="md">
<FlexContainer>
<FlexItem grow>
<LogSearchInput
ref={searchInputRef}
inputValue={inputValue}
onSearchInputKeydown={onSearchInputKeydown}
onSearchTermChange={onSearchTermChange}
highlightedMatchDisplay={highlightedMatchingLineNumber}
highlightedMatchIndex={highlightedMatchIndex}
matches={matchingLines}
scrollToNextMatch={scrollToNextMatch}
scrollToPreviousMatch={scrollToPreviousMatch}
/>
</FlexItem>
{showStructuredLogs && (
<>
<FlexItem>
<MultiListBox
selectedValues={selectedLogSources ?? sources}
options={logSourceOptions}
onSelectValues={(newSources) => setSelectedLogSources(newSources ?? sources)}
label="Log sources"
/>
</FlexItem>
<FlexItem>
<MultiListBox
selectedValues={selectedLogLevels ?? levels}
options={logLevelOptions}
onSelectValues={(newLevels) => setSelectedLogLevels(newLevels ?? levels)}
label="Log levels"
/>
</FlexItem>
</>
)}
</FlexContainer>
</Box>

{sources.length > 0 && (
<Box px="md">
<FlexContainer gap="lg">
{logSourceOptions.map((option) => (
<label key={option.value}>
<FlexContainer key={option.value} alignItems="center" as="span" display="inline-flex" gap="sm">
<Switch
size="xs"
checked={selectedLogSources?.includes(option.value) ?? true}
onChange={() => onSelectLogSource(option.value)}
/>
<Text>{option.label}</Text>
</FlexContainer>
</label>
))}
</FlexContainer>
</Box>
)}

{logLines.length === 0 && (
<Box p="xl">
<FlexContainer justifyContent="center">
<Text>
<FormattedMessage id="jobHistory.logs.noLogsFound" />
</Text>
</FlexContainer>
</Box>
)}

<VirtualLogs
attemptId={attempt.attempt.id}
logLines={filteredLogLines}
searchTerm={debouncedSearchTerm}
scrollTo={scrollTo}
hasFailure={!!attempt.attempt.failureSummary}
showStructuredLogs={showStructuredLogs}
/>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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<DownloadButtonProps> = ({ logLines, fileName }) => {
export const DownloadLogsButton: React.FC<DownloadButtonProps> = ({ 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 (
<Tooltip
control={
<Button
onClick={downloadFileWithLogs}
onClick={downloadLogs}
variant="secondary"
icon="download"
aria-label={formatMessage({ id: "jobHistory.logs.downloadLogs" })}
Expand Down
Loading

0 comments on commit d300367

Please sign in to comment.