mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
@@ -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>>;
|
||||
|
||||
Reference in New Issue
Block a user