fix: let workspace pages download partial logs for unhealthy workspaces (#13761)

* fix: get basic fix in for  preventing download logs from blowing up UI

* fix: make sure blob units can't go out of bounds

* fix: make sure timeout is cleared on component unmount

* fix: reduce risk of shared cache state breaking useAgentLogs

* fix: allow partial downloading of logs

* fix: make sure useMemo cache is used properly

* wip: commit current progress on updated logs functionality

* docs: rewrite comment for clarity

* refactor: clean up current code

* fix: update styles for unavailable logs

* fix: resolve linter violations

* fix: update type signature of getErrorDetail

* fix: revert log/enabled logic for useAgentLogs

* fix: remove memoization from DownloadLogsDialog

* fix: update name of timeout state

* refactor: make log web sockets logic more clear

* docs: reword comment for clarity

* fix: commit current style update progress

* fix: finish style updates
This commit is contained in:
Michael Smith
2024-07-03 10:17:54 -04:00
committed by GitHub
parent 07d41716ad
commit 940afa1ab1
3 changed files with 235 additions and 70 deletions
+5 -2
View File
@@ -110,15 +110,18 @@ export const getValidationErrorMessage = (error: unknown): string => {
return validationErrors.map((error) => error.detail).join("\n");
};
export const getErrorDetail = (error: unknown): string | undefined | null => {
export const getErrorDetail = (error: unknown): string | undefined => {
if (error instanceof Error) {
return "Please check the developer console for more details.";
}
if (isApiError(error)) {
return error.response.data.detail;
}
if (isApiErrorResponse(error)) {
return error.detail;
}
return null;
return undefined;
};
@@ -15,22 +15,35 @@ export type UseAgentLogsOptions = Readonly<{
enabled?: boolean;
}>;
/**
* Defines a custom hook that gives you all workspace agent logs for a given
* workspace.
*
* Depending on the status of the workspace, all logs may or may not be
* available.
*/
export function useAgentLogs(
options: UseAgentLogsOptions,
): readonly WorkspaceAgentLog[] | undefined {
const { workspaceId, agentId, agentLifeCycleState, enabled = true } = options;
const queryClient = useQueryClient();
const queryOptions = agentLogs(workspaceId, agentId);
const query = useQuery({
...queryOptions,
enabled,
});
const logs = query.data;
const { data: logs, isFetched } = useQuery({ ...queryOptions, enabled });
// Track the ID of the last log received when the initial logs response comes
// back. If the logs are not complete, the ID will mark the start point of the
// Web sockets response so that the remaining logs can be received over time
const lastQueriedLogId = useRef(0);
useEffect(() => {
if (logs && lastQueriedLogId.current === 0) {
lastQueriedLogId.current = logs[logs.length - 1].id;
const isAlreadyTracking = lastQueriedLogId.current !== 0;
if (isAlreadyTracking) {
return;
}
const lastLog = logs?.at(-1);
if (lastLog !== undefined) {
lastQueriedLogId.current = lastLog.id;
}
}, [logs]);
@@ -42,7 +55,7 @@ export function useAgentLogs(
});
useEffect(() => {
if (agentLifeCycleState !== "starting" || !query.isFetched) {
if (agentLifeCycleState !== "starting" || !isFetched) {
return;
}
@@ -69,7 +82,7 @@ export function useAgentLogs(
return () => {
socket.close();
};
}, [addLogs, agentId, agentLifeCycleState, query.isFetched]);
}, [addLogs, agentId, agentLifeCycleState, isFetched]);
return logs;
}
@@ -2,10 +2,11 @@ import { useTheme, type Interpolation, type Theme } from "@emotion/react";
import Skeleton from "@mui/material/Skeleton";
import { saveAs } from "file-saver";
import JSZip from "jszip";
import { useMemo, useState, type FC } from "react";
import { type FC, useMemo, useState, useRef, useEffect } from "react";
import { useQueries, useQuery } from "react-query";
import { agentLogs, buildLogs } from "api/queries/workspaces";
import type { Workspace, WorkspaceAgent } from "api/typesGenerated";
import { Alert } from "components/Alert/Alert";
import {
ConfirmDialog,
type ConfirmDialogProps,
@@ -28,70 +29,107 @@ type DownloadableFile = {
export const DownloadLogsDialog: FC<DownloadLogsDialogProps> = ({
workspace,
open,
onClose,
download = saveAs,
...dialogProps
}) => {
const theme = useTheme();
const agents = selectAgents(workspace);
const agentLogResults = useQueries({
queries: agents.map((a) => ({
...agentLogs(workspace.id, a.id),
enabled: dialogProps.open,
})),
});
const buildLogsQuery = useQuery({
...buildLogs(workspace),
enabled: dialogProps.open,
enabled: open,
});
const downloadableFiles: DownloadableFile[] = useMemo(() => {
const files: DownloadableFile[] = [
{
name: `${workspace.name}-build-logs.txt`,
blob: buildLogsQuery.data
? new Blob([buildLogsQuery.data.map((l) => l.output).join("\n")], {
type: "text/plain",
})
: undefined,
},
];
agents.forEach((a, i) => {
const allUniqueAgents = useMemo<readonly WorkspaceAgent[]>(() => {
const allAgents = workspace.latest_build.resources.flatMap(
(resource) => resource.agents ?? [],
);
// Can't use the "new Set()" trick because we're not dealing with primitives
const uniqueAgents = new Map(allAgents.map((agent) => [agent.id, agent]));
const iterable = [...uniqueAgents.values()];
return iterable;
}, [workspace.latest_build.resources]);
const agentLogQueries = useQueries({
queries: allUniqueAgents.map((agent) => ({
...agentLogs(workspace.id, agent.id),
enabled: open,
})),
});
// Note: trying to memoize this via useMemo got really clunky. Removing all
// memoization for now, but if we get to a point where performance matters,
// we should make it so that this state doesn't even begin to mount until the
// user decides to open the Logs dropdown
const allFiles: readonly DownloadableFile[] = (() => {
const files = allUniqueAgents.map<DownloadableFile>((a, i) => {
const name = `${a.name}-logs.txt`;
const logs = agentLogResults[i].data;
const txt = logs?.map((l) => l.output).join("\n");
const txt = agentLogQueries[i]?.data?.map((l) => l.output).join("\n");
let blob: Blob | undefined;
if (txt) {
blob = new Blob([txt], { type: "text/plain" });
}
files.push({ name, blob });
return { name, blob };
});
const buildLogsFile = {
name: `${workspace.name}-build-logs.txt`,
blob: buildLogsQuery.data
? new Blob([buildLogsQuery.data.map((l) => l.output).join("\n")], {
type: "text/plain",
})
: undefined,
};
files.unshift(buildLogsFile);
return files;
}, [agentLogResults, agents, buildLogsQuery.data, workspace.name]);
const isLoadingFiles = downloadableFiles.some((f) => f.blob === undefined);
})();
const [isDownloading, setIsDownloading] = useState(false);
const isWorkspaceHealthy = workspace.health.healthy;
const isLoadingFiles = allFiles.some((f) => f.blob === undefined);
const downloadTimeoutIdRef = useRef<number | undefined>(undefined);
useEffect(() => {
const clearTimeoutOnUnmount = () => {
window.clearTimeout(downloadTimeoutIdRef.current);
};
return clearTimeoutOnUnmount;
}, []);
return (
<ConfirmDialog
{...dialogProps}
open={open}
onClose={onClose}
hideCancel={false}
title="Download logs"
confirmText="Download"
disabled={isLoadingFiles}
confirmLoading={isDownloading}
confirmText="Download"
disabled={
isDownloading ||
// If a workspace isn't healthy, let the user download as many logs as
// they can. Otherwise, wait for everything to come in
(isWorkspaceHealthy && isLoadingFiles)
}
onConfirm={async () => {
setIsDownloading(true);
const zip = new JSZip();
allFiles.forEach((f) => {
if (f.blob) {
zip.file(f.name, f.blob);
}
});
try {
setIsDownloading(true);
const zip = new JSZip();
downloadableFiles.forEach((f) => {
if (f.blob) {
zip.file(f.name, f.blob);
}
});
const content = await zip.generateAsync({ type: "blob" });
download(content, `${workspace.name}-logs.zip`);
dialogProps.onClose();
setTimeout(() => {
onClose();
downloadTimeoutIdRef.current = window.setTimeout(() => {
setIsDownloading(false);
}, theme.transitions.duration.leavingScreen);
} catch (error) {
@@ -106,18 +144,21 @@ export const DownloadLogsDialog: FC<DownloadLogsDialogProps> = ({
Downloading logs will create a zip file containing all logs from all
jobs in this workspace. This may take a while.
</p>
{!isWorkspaceHealthy && isLoadingFiles && (
<Alert severity="warning">
Your workspace is unhealthy. Some logs may be unavailable for
download.
</Alert>
)}
<ul css={styles.list}>
{downloadableFiles.map((f) => (
<li key={f.name} css={styles.listItem}>
<span css={styles.listItemPrimary}>{f.name}</span>
<span css={styles.listItemSecondary}>
{f.blob ? (
humanBlobSize(f.blob.size)
) : (
<Skeleton variant="text" width={48} height={12} />
)}
</span>
</li>
{allFiles.map((f) => (
<DownloadingItem
key={f.name}
file={f}
giveUpTimeMs={isWorkspaceHealthy ? undefined : 5_000}
/>
))}
</ul>
</Stack>
@@ -126,20 +167,98 @@ export const DownloadLogsDialog: FC<DownloadLogsDialogProps> = ({
);
};
type DownloadingItemProps = Readonly<{
// A value of undefined indicates that the component will wait forever
giveUpTimeMs?: number;
file: DownloadableFile;
}>;
const DownloadingItem: FC<DownloadingItemProps> = ({ file, giveUpTimeMs }) => {
const theme = useTheme();
const [isWaiting, setIsWaiting] = useState(true);
useEffect(() => {
if (giveUpTimeMs === undefined || file.blob !== undefined) {
setIsWaiting(true);
return;
}
const timeoutId = window.setTimeout(
() => setIsWaiting(false),
giveUpTimeMs,
);
return () => window.clearTimeout(timeoutId);
}, [giveUpTimeMs, file]);
const { baseName, fileExtension } = extractFileNameInfo(file.name);
return (
<li css={styles.listItem}>
<span
css={[
styles.listItemPrimary,
!isWaiting && { color: theme.palette.text.disabled },
]}
>
<span css={styles.listItemPrimaryBaseName}>{baseName}</span>
<span css={styles.listItemPrimaryFileExtension}>.{fileExtension}</span>
</span>
<span css={styles.listItemSecondary}>
{file.blob ? (
humanBlobSize(file.blob.size)
) : isWaiting ? (
<Skeleton variant="text" width={48} height={12} />
) : (
<p css={styles.notAvailableText}>Not available</p>
)}
</span>
</li>
);
};
function humanBlobSize(size: number) {
const units = ["B", "KB", "MB", "GB", "TB"];
const BLOB_SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
let i = 0;
while (size > 1024 && i < units.length) {
while (size > 1024 && i < BLOB_SIZE_UNITS.length) {
size /= 1024;
i++;
}
return `${size.toFixed(2)} ${units[i]}`;
// The condition for the while loop above means that over time, we could break
// out of the loop because we accidentally shot past the array bounds and i
// is at index (BLOB_SIZE_UNITS.length). Adding a lot of redundant checks to
// make sure we always have a usable unit
const finalUnit = BLOB_SIZE_UNITS[i] ?? BLOB_SIZE_UNITS.at(-1) ?? "TB";
return `${size.toFixed(2)} ${finalUnit}`;
}
function selectAgents(workspace: Workspace): WorkspaceAgent[] {
return workspace.latest_build.resources
.flatMap((r) => r.agents)
.filter((a) => a !== undefined) as WorkspaceAgent[];
type FileNameInfo = Readonly<{
baseName: string;
fileExtension: string | undefined;
}>;
function extractFileNameInfo(filename: string): FileNameInfo {
if (filename.length === 0) {
return {
baseName: "",
fileExtension: undefined,
};
}
const periodIndex = filename.lastIndexOf(".");
if (periodIndex === -1) {
return {
baseName: filename,
fileExtension: undefined,
};
}
return {
baseName: filename.slice(0, periodIndex),
fileExtension: filename.slice(periodIndex + 1),
};
}
const styles = {
@@ -151,16 +270,46 @@ const styles = {
flexDirection: "column",
gap: 8,
},
listItem: {
width: "100%",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
columnGap: "32px",
},
listItemPrimary: (theme) => ({
fontWeight: 500,
color: theme.palette.text.primary,
display: "flex",
flexFlow: "row nowrap",
columnGap: 0,
overflow: "hidden",
}),
listItemSecondary: {
fontSize: 14,
listItemPrimaryBaseName: {
minWidth: 0,
flexShrink: 1,
overflow: "hidden",
textOverflow: "ellipsis",
},
listItemPrimaryFileExtension: {
flexShrink: 0,
},
listItemSecondary: {
flexShrink: 0,
fontSize: 14,
whiteSpace: "nowrap",
},
notAvailableText: (theme) => ({
display: "flex",
flexFlow: "row nowrap",
alignItems: "center",
columnGap: "4px",
color: theme.palette.text.disabled,
}),
} satisfies Record<string, Interpolation<Theme>>;