diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts
index ada591d754fb2..621b19856601b 100644
--- a/site/src/api/errors.ts
+++ b/site/src/api/errors.ts
@@ -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;
};
diff --git a/site/src/modules/resources/AgentLogs/useAgentLogs.ts b/site/src/modules/resources/AgentLogs/useAgentLogs.ts
index e5d797a14e9c2..943dfcc194396 100644
--- a/site/src/modules/resources/AgentLogs/useAgentLogs.ts
+++ b/site/src/modules/resources/AgentLogs/useAgentLogs.ts
@@ -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;
}
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
index aefd4d7d6b9e1..ab1ee817a9de7 100644
--- a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
@@ -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 = ({
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(() => {
+ 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((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(undefined);
+ useEffect(() => {
+ const clearTimeoutOnUnmount = () => {
+ window.clearTimeout(downloadTimeoutIdRef.current);
+ };
+
+ return clearTimeoutOnUnmount;
+ }, []);
return (
{
+ 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 = ({
Downloading logs will create a zip file containing all logs from all
jobs in this workspace. This may take a while.
+
+ {!isWorkspaceHealthy && isLoadingFiles && (
+
+ Your workspace is unhealthy. Some logs may be unavailable for
+ download.
+
+ )}
+
- {downloadableFiles.map((f) => (
- -
- {f.name}
-
- {f.blob ? (
- humanBlobSize(f.blob.size)
- ) : (
-
- )}
-
-
+ {allFiles.map((f) => (
+
))}
@@ -126,20 +167,98 @@ export const DownloadLogsDialog: FC = ({
);
};
+type DownloadingItemProps = Readonly<{
+ // A value of undefined indicates that the component will wait forever
+ giveUpTimeMs?: number;
+ file: DownloadableFile;
+}>;
+
+const DownloadingItem: FC = ({ 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 (
+
+
+ {baseName}
+ .{fileExtension}
+
+
+
+ {file.blob ? (
+ humanBlobSize(file.blob.size)
+ ) : isWaiting ? (
+
+ ) : (
+ Not available
+ )}
+
+
+ );
+};
+
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",
}),
+
+ 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>;