From 051217abcabe2fdd46f89a2b2f0be9fc497d6a35 Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Mon, 3 Jun 2024 17:10:02 +0000
Subject: [PATCH 01/26] Download agent logs
---
package.json | 5 +-
pnpm-lock.yaml | 86 +++++++++++++++++++
site/src/modules/resources/AgentRow.test.tsx | 3 +-
site/src/modules/resources/AgentRow.tsx | 49 ++++-------
.../resources/DownloadAgentLogsButton.tsx | 40 +++++++++
5 files changed, 147 insertions(+), 36 deletions(-)
create mode 100644 site/src/modules/resources/DownloadAgentLogsButton.tsx
diff --git a/package.json b/package.json
index b290e5990874d..451b677df4588 100644
--- a/package.json
+++ b/package.json
@@ -7,10 +7,13 @@
"storybook": "pnpm run -C site/ storybook"
},
"devDependencies": {
+ "@types/file-saver": "^2.0.7",
"prettier": "3.0.0"
},
"dependencies": {
- "exec": "^0.2.1"
+ "exec": "^0.2.1",
+ "file-saver": "^2.0.5",
+ "jszip": "^3.10.1"
},
"packageManager": "pnpm@8.14.0+sha1.bb42032ff80dba5f9245bc1b03470d2fa0b7fb2f"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e5e4d2584e40f..f4a45c1f288d8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,22 +8,108 @@ dependencies:
exec:
specifier: ^0.2.1
version: 0.2.1
+ file-saver:
+ specifier: ^2.0.5
+ version: 2.0.5
+ jszip:
+ specifier: ^3.10.1
+ version: 3.10.1
devDependencies:
+ '@types/file-saver':
+ specifier: ^2.0.7
+ version: 2.0.7
prettier:
specifier: 3.0.0
version: 3.0.0
packages:
+ /@types/file-saver@2.0.7:
+ resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==}
+ dev: true
+
+ /core-util-is@1.0.3:
+ resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
+ dev: false
+
/exec@0.2.1:
resolution: {integrity: sha512-lE5ZlJgRYh+rmwidatL2AqRA/U9IBoCpKlLriBmnfUIrV/Rj4oLjb63qZ57iBCHWi5j9IjLt5wOWkFYPiTfYAg==}
engines: {node: '>= v0.9.1'}
deprecated: deprecated in favor of builtin child_process.execFile
dev: false
+ /file-saver@2.0.5:
+ resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==}
+ dev: false
+
+ /immediate@3.0.6:
+ resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+ dev: false
+
+ /inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+ dev: false
+
+ /isarray@1.0.0:
+ resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
+ dev: false
+
+ /jszip@3.10.1:
+ resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+ dependencies:
+ lie: 3.3.0
+ pako: 1.0.11
+ readable-stream: 2.3.8
+ setimmediate: 1.0.5
+ dev: false
+
+ /lie@3.3.0:
+ resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+ dependencies:
+ immediate: 3.0.6
+ dev: false
+
+ /pako@1.0.11:
+ resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+ dev: false
+
/prettier@3.0.0:
resolution: {integrity: sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==}
engines: {node: '>=14'}
hasBin: true
dev: true
+
+ /process-nextick-args@2.0.1:
+ resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+ dev: false
+
+ /readable-stream@2.3.8:
+ resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
+ dependencies:
+ core-util-is: 1.0.3
+ inherits: 2.0.4
+ isarray: 1.0.0
+ process-nextick-args: 2.0.1
+ safe-buffer: 5.1.2
+ string_decoder: 1.1.1
+ util-deprecate: 1.0.2
+ dev: false
+
+ /safe-buffer@5.1.2:
+ resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
+ dev: false
+
+ /setimmediate@1.0.5:
+ resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
+ dev: false
+
+ /string_decoder@1.1.1:
+ resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
+ dependencies:
+ safe-buffer: 5.1.2
+ dev: false
+
+ /util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+ dev: false
diff --git a/site/src/modules/resources/AgentRow.test.tsx b/site/src/modules/resources/AgentRow.test.tsx
index 4ae3f1536b659..5e83e22f91c94 100644
--- a/site/src/modules/resources/AgentRow.test.tsx
+++ b/site/src/modules/resources/AgentRow.test.tsx
@@ -8,7 +8,8 @@ import {
renderWithAuth,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
-import { AgentRow, type AgentRowProps } from "./AgentRow";
+import { type AgentRowProps } from "./AgentRow";
+import { AgentRow } from "./AgentRow";
import { DisplayAppNameMap } from "./AppLink/AppLink";
jest.mock("modules/resources/AgentMetadata", () => {
diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx
index 145b5add7d25b..7ac656acf38c3 100644
--- a/site/src/modules/resources/AgentRow.tsx
+++ b/site/src/modules/resources/AgentRow.tsx
@@ -1,5 +1,7 @@
import type { Interpolation, Theme } from "@emotion/react";
+import Button from "@mui/material/Button";
import Collapse from "@mui/material/Collapse";
+import Divider from "@mui/material/Divider";
import Skeleton from "@mui/material/Skeleton";
import {
type FC,
@@ -33,6 +35,7 @@ import { AgentMetadata } from "./AgentMetadata";
import { AgentStatus } from "./AgentStatus";
import { AgentVersion } from "./AgentVersion";
import { AppLink } from "./AppLink/AppLink";
+import { DownloadAgentLogsButton } from "./DownloadAgentLogsButton";
import { PortForwardButton } from "./PortForwardButton";
import { SSHButton } from "./SSHButton/SSHButton";
import { TerminalLink } from "./TerminalLink/TerminalLink";
@@ -93,7 +96,6 @@ export const AgentRow: FC = ({
hasStartupFeatures,
);
const agentLogs = useAgentLogs(agent.id, {
- enabled: showLogs,
initialData: process.env.STORYBOOK ? storybookLogs || [] : undefined,
});
const logListRef = useRef(null);
@@ -296,13 +298,18 @@ export const AgentRow: FC = ({
-
+
+ }
+ onClick={() => setShowLogs((v) => !v)}
+ >
+ Agent logs
+
+
+
+
)}
@@ -475,32 +482,6 @@ const styles = {
},
}),
- logsPanelButton: (theme) => ({
- textAlign: "left",
- background: "transparent",
- border: 0,
- fontFamily: "inherit",
- padding: "16px 32px",
- color: theme.palette.text.secondary,
- cursor: "pointer",
- display: "flex",
- alignItems: "center",
- gap: 8,
- whiteSpace: "nowrap",
- width: "100%",
- borderBottomLeftRadius: 8,
- borderBottomRightRadius: 8,
-
- "&:hover": {
- color: theme.palette.text.primary,
- backgroundColor: theme.experimental.l2.hover.background,
- },
-
- "& svg": {
- color: "inherit",
- },
- }),
-
buttonSkeleton: {
borderRadius: 4,
},
diff --git a/site/src/modules/resources/DownloadAgentLogsButton.tsx b/site/src/modules/resources/DownloadAgentLogsButton.tsx
new file mode 100644
index 0000000000000..0567918ad3ba9
--- /dev/null
+++ b/site/src/modules/resources/DownloadAgentLogsButton.tsx
@@ -0,0 +1,40 @@
+import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
+import Button from "@mui/material/Button";
+import { saveAs } from "file-saver";
+import type { FC } from "react";
+import type { WorkspaceAgent } from "api/typesGenerated";
+import type { LineWithID } from "./AgentLogs/AgentLogLine";
+
+type DownloadAgentLogsButtonProps = {
+ agent: Pick;
+ logs: LineWithID[] | undefined;
+ onDownload?: (file: Blob, filename: string) => void;
+};
+
+export const DownloadAgentLogsButton: FC = ({
+ agent,
+ logs,
+ onDownload = saveAs,
+}) => {
+ const isDisabled =
+ agent.status !== "connected" || logs === undefined || logs.length === 0;
+
+ return (
+ }
+ onClick={() => {
+ if (isDisabled) {
+ return;
+ }
+ const text = logs.map((l) => l.output).join("\n");
+ const file = new Blob([text], { type: "text/plain" });
+ onDownload(file, `${agent.name}-logs.txt`);
+ }}
+ >
+ Download
+
+ );
+};
From c9af899adec26d9030f08814511c2a18ea07d0e7 Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Mon, 3 Jun 2024 18:34:07 +0000
Subject: [PATCH 02/26] Download workspace logs
---
.../WorkspaceActions/DownloadLogsDialog.tsx | 169 ++++++++++++++++++
.../WorkspaceActions/WorkspaceActions.tsx | 18 +-
2 files changed, 186 insertions(+), 1 deletion(-)
create mode 100644 site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
new file mode 100644
index 0000000000000..609f14dae67e2
--- /dev/null
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
@@ -0,0 +1,169 @@
+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 { useQueries, useQuery } from "react-query";
+import { API } from "api/api";
+import type { Workspace, WorkspaceAgent } from "api/typesGenerated";
+import {
+ ConfirmDialog,
+ type ConfirmDialogProps,
+} from "components/Dialogs/ConfirmDialog/ConfirmDialog";
+import { displayError } from "components/GlobalSnackbar/utils";
+import { Stack } from "components/Stack/Stack";
+
+type DownloadLogsDialogProps = Pick<
+ ConfirmDialogProps,
+ "onConfirm" | "onClose" | "open"
+> & {
+ workspace: Workspace;
+};
+
+type DownloadFile = {
+ name: string;
+ blob: Blob | undefined;
+};
+
+export const DownloadLogsDialog: FC = ({
+ workspace,
+ ...dialogProps
+}) => {
+ const theme = useTheme();
+ const agents = selectAgents(workspace);
+ const agentLogResults = useQueries({
+ queries: agents.map((a) => ({
+ queryKey: ["workspaces", workspace.id, "agents", a.id, "logs"],
+ queryFn: () => API.getWorkspaceAgentLogs(a.id),
+ enabled: dialogProps.open,
+ })),
+ });
+ const buildLogs = useQuery({
+ queryKey: ["workspaces", workspace.id, "logs"],
+ queryFn: () =>
+ API.getWorkspaceBuildLogs(
+ workspace.latest_build.id,
+ new Date(workspace.latest_build.created_at),
+ ),
+ enabled: dialogProps.open,
+ });
+ const downloadFiles: DownloadFile[] = useMemo(() => {
+ const files: DownloadFile[] = [];
+
+ files.push({
+ name: `${workspace.name}-build-logs.txt`,
+ blob: buildLogs.data
+ ? new Blob([buildLogs.data.map((l) => l.output).join("\n")], {
+ type: "text/plain",
+ })
+ : undefined,
+ });
+
+ agents.forEach((a, i) => {
+ const name = `${a.name}-logs.txt`;
+ const logs = agentLogResults[i].data;
+ const txt = logs?.map((l) => l.output).join("\n");
+ let blob: Blob | undefined;
+ if (txt) {
+ blob = new Blob([txt], { type: "text/plain" });
+ }
+ files.push({ name, blob });
+ });
+
+ return files;
+ }, [agentLogResults, agents, buildLogs.data, workspace.name]);
+ const isLoadingFiles = downloadFiles.some((f) => f.blob === undefined);
+ const [isDownloading, setIsDownloading] = useState(false);
+
+ return (
+ {
+ try {
+ setIsDownloading(true);
+ const zip = new JSZip();
+ downloadFiles.forEach((f) => {
+ if (f.blob) {
+ zip.file(f.name, f.blob);
+ }
+ });
+ const content = await zip.generateAsync({ type: "blob" });
+ saveAs(content, `${workspace.name}-logs.zip`);
+ dialogProps.onClose();
+ setTimeout(() => {
+ setIsDownloading(false);
+ }, theme.transitions.duration.leavingScreen);
+ } catch (error) {
+ setIsDownloading(false);
+ displayError("Error downloading workspace logs");
+ console.error(error);
+ }
+ }}
+ description={
+
+
+ Downloading logs will create a zip file containing all logs from all
+ jobs in this workspace. This may take a while.
+
+
+ {downloadFiles.map((f) => (
+ -
+ {f.name}
+
+ {f.blob ? (
+ humanBlobSize(f.blob.size)
+ ) : (
+
+ )}
+
+
+ ))}
+
+
+ }
+ />
+ );
+};
+
+function humanBlobSize(size: number) {
+ const units = ["B", "KB", "MB", "GB", "TB"];
+ let i = 0;
+ while (size > 1024 && i < units.length) {
+ size /= 1024;
+ i++;
+ }
+ return `${size.toFixed(2)} ${units[i]}`;
+}
+
+function selectAgents(workspace: Workspace): WorkspaceAgent[] {
+ return workspace.latest_build.resources
+ .flatMap((r) => r.agents)
+ .filter((a) => a !== undefined) as WorkspaceAgent[];
+}
+
+const styles = {
+ list: {
+ listStyle: "none",
+ padding: 0,
+ margin: 0,
+ display: "flex",
+ flexDirection: "column",
+ gap: 8,
+ },
+ listItem: {
+ display: "flex",
+ justifyContent: "space-between",
+ },
+ listItemPrimary: (theme) => ({
+ fontWeight: 500,
+ color: theme.palette.text.primary,
+ }),
+ listItemSecondary: {
+ fontSize: 14,
+ },
+} satisfies Record>;
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx
index ad79ce1be9c95..b149e511b5254 100644
--- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx
@@ -1,10 +1,11 @@
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
+import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
import DuplicateIcon from "@mui/icons-material/FileCopyOutlined";
import HistoryIcon from "@mui/icons-material/HistoryOutlined";
import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined";
import SettingsIcon from "@mui/icons-material/SettingsOutlined";
import Divider from "@mui/material/Divider";
-import { type FC, type ReactNode, Fragment } from "react";
+import { type FC, type ReactNode, Fragment, useState } from "react";
import type { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
import { TopbarIconButton } from "components/FullPageLayout/Topbar";
import {
@@ -28,6 +29,7 @@ import {
} from "./Buttons";
import { type ActionType, abilitiesByWorkspaceStatus } from "./constants";
import { DebugButton } from "./DebugButton";
+import { DownloadLogsDialog } from "./DownloadLogsDialog";
import { RetryButton } from "./RetryButton";
export interface WorkspaceActionsProps {
@@ -75,6 +77,8 @@ export const WorkspaceActions: FC = ({
const { duplicateWorkspace, isDuplicationReady } =
useWorkspaceDuplication(workspace);
+ const [isDownloadDialogOpen, setIsDownloadDialogOpen] = useState(false);
+
const { actions, canCancel, canAcceptJobs } = abilitiesByWorkspaceStatus(
workspace,
canDebug,
@@ -215,6 +219,11 @@ export const WorkspaceActions: FC = ({
Duplicate…
+ setIsDownloadDialogOpen(true)}>
+
+ Download logs…
+
+
= ({
+
+ setIsDownloadDialogOpen(false)}
+ onConfirm={() => {}}
+ />
);
};
From e479cf2a86e4f935c57408e3e5fc646c284f5e27 Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Mon, 3 Jun 2024 18:34:56 +0000
Subject: [PATCH 03/26] Use Logs instead of Agent Logs
---
site/src/modules/resources/AgentRow.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx
index 7ac656acf38c3..6a6ef22695b20 100644
--- a/site/src/modules/resources/AgentRow.tsx
+++ b/site/src/modules/resources/AgentRow.tsx
@@ -305,7 +305,7 @@ export const AgentRow: FC = ({
startIcon={}
onClick={() => setShowLogs((v) => !v)}
>
- Agent logs
+ Logs
From 9ccc9e124064d7b1cfd7efac134eb25433b83b34 Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Mon, 3 Jun 2024 20:02:00 +0000
Subject: [PATCH 04/26] Centralize items list
---
.../pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
index 609f14dae67e2..d1b1bfae6b481 100644
--- a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
@@ -158,6 +158,7 @@ const styles = {
listItem: {
display: "flex",
justifyContent: "space-between",
+ alignItems: "center",
},
listItemPrimary: (theme) => ({
fontWeight: 500,
From 128f93888dea9b0bf4ef3153c44c259407ff425b Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Mon, 3 Jun 2024 20:05:39 +0000
Subject: [PATCH 05/26] Init files with build logs
---
.../WorkspaceActions/DownloadLogsDialog.tsx | 20 +++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
index d1b1bfae6b481..42c889ef81b13 100644
--- a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
@@ -48,16 +48,16 @@ export const DownloadLogsDialog: FC = ({
enabled: dialogProps.open,
});
const downloadFiles: DownloadFile[] = useMemo(() => {
- const files: DownloadFile[] = [];
-
- files.push({
- name: `${workspace.name}-build-logs.txt`,
- blob: buildLogs.data
- ? new Blob([buildLogs.data.map((l) => l.output).join("\n")], {
- type: "text/plain",
- })
- : undefined,
- });
+ const files: DownloadFile[] = [
+ {
+ name: `${workspace.name}-build-logs.txt`,
+ blob: buildLogs.data
+ ? new Blob([buildLogs.data.map((l) => l.output).join("\n")], {
+ type: "text/plain",
+ })
+ : undefined,
+ },
+ ];
agents.forEach((a, i) => {
const name = `${a.name}-logs.txt`;
From 4d5c9cb88c497872b1319755612e849e48fc7918 Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Tue, 4 Jun 2024 13:53:24 +0000
Subject: [PATCH 06/26] Add tests for the download logs dialog
---
site/src/api/queries/workspaces.ts | 32 +++++++++
.../DownloadLogsDialog.stories.tsx | 71 +++++++++++++++++++
.../WorkspaceActions/DownloadLogsDialog.tsx | 24 +++----
3 files changed, 113 insertions(+), 14 deletions(-)
create mode 100644 site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx
diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts
index 95df3b7f592f6..b138dc989cc2c 100644
--- a/site/src/api/queries/workspaces.ts
+++ b/site/src/api/queries/workspaces.ts
@@ -283,3 +283,35 @@ export const toggleFavorite = (
},
};
};
+
+export const buildLogsKey = (workspaceId: string) => [
+ "workspaces",
+ workspaceId,
+ "logs",
+];
+
+export const buildLogs = (workspace: Workspace) => {
+ return {
+ queryKey: buildLogsKey(workspace.id),
+ queryFn: () =>
+ API.getWorkspaceBuildLogs(
+ workspace.latest_build.id,
+ new Date(workspace.latest_build.created_at),
+ ),
+ };
+};
+
+export const agentLogsKey = (workspaceId: string, agentId: string) => [
+ "workspaces",
+ workspaceId,
+ "agents",
+ agentId,
+ "logs",
+];
+
+export const agentLogs = (workspaceId: string, agentId: string) => {
+ return {
+ queryKey: agentLogsKey(workspaceId, agentId),
+ queryFn: () => API.getWorkspaceAgentLogs(agentId),
+ };
+};
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx
new file mode 100644
index 0000000000000..04c4429c21509
--- /dev/null
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx
@@ -0,0 +1,71 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { expect, fireEvent, fn, waitFor, within } from "@storybook/test";
+import { agentLogsKey, buildLogsKey } from "api/queries/workspaces";
+import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
+import { DownloadLogsDialog } from "./DownloadLogsDialog";
+
+const meta: Meta = {
+ title: "pages/WorkspacePage/DownloadLogsDialog",
+ component: DownloadLogsDialog,
+ args: {
+ open: true,
+ workspace: MockWorkspace,
+ onClose: fn(),
+ },
+ parameters: {
+ queries: [
+ {
+ key: buildLogsKey(MockWorkspace.id),
+ data: generateLogs(200),
+ },
+ {
+ key: agentLogsKey(MockWorkspace.id, MockWorkspaceAgent.id),
+ data: generateLogs(400),
+ },
+ ],
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Ready: Story = {};
+
+export const Loading: Story = {
+ parameters: {
+ queries: [
+ {
+ key: buildLogsKey(MockWorkspace.id),
+ data: undefined,
+ },
+ {
+ key: agentLogsKey(MockWorkspace.id, MockWorkspaceAgent.id),
+ data: undefined,
+ },
+ ],
+ },
+};
+
+export const DownloadLogs: Story = {
+ args: {
+ download: fn(),
+ },
+ play: async ({ args }) => {
+ const screen = within(document.body);
+ await fireEvent.click(screen.getByRole("button", { name: "Download" }));
+ await waitFor(() =>
+ expect(args.download).toHaveBeenCalledWith(
+ expect.anything(),
+ `${MockWorkspace.name}-logs.zip`,
+ ),
+ );
+ const blob: Blob = (args.download as jest.Mock).mock.calls[0][0];
+ await expect(blob.type).toEqual("application/zip");
+ },
+};
+
+function generateLogs(count: number) {
+ return Array.from({ length: count }, (_, i) => ({
+ output: `log ${i + 1}`,
+ }));
+}
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
index 42c889ef81b13..8d6090469b835 100644
--- a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
@@ -4,7 +4,7 @@ import { saveAs } from "file-saver";
import JSZip from "jszip";
import { useMemo, useState, type FC } from "react";
import { useQueries, useQuery } from "react-query";
-import { API } from "api/api";
+import { agentLogs, buildLogs } from "api/queries/workspaces";
import type { Workspace, WorkspaceAgent } from "api/typesGenerated";
import {
ConfirmDialog,
@@ -18,6 +18,7 @@ type DownloadLogsDialogProps = Pick<
"onConfirm" | "onClose" | "open"
> & {
workspace: Workspace;
+ download?: (zip: Blob, filename: string) => void;
};
type DownloadFile = {
@@ -27,32 +28,27 @@ type DownloadFile = {
export const DownloadLogsDialog: FC = ({
workspace,
+ download = saveAs,
...dialogProps
}) => {
const theme = useTheme();
const agents = selectAgents(workspace);
const agentLogResults = useQueries({
queries: agents.map((a) => ({
- queryKey: ["workspaces", workspace.id, "agents", a.id, "logs"],
- queryFn: () => API.getWorkspaceAgentLogs(a.id),
+ ...agentLogs(workspace.id, a.id),
enabled: dialogProps.open,
})),
});
- const buildLogs = useQuery({
- queryKey: ["workspaces", workspace.id, "logs"],
- queryFn: () =>
- API.getWorkspaceBuildLogs(
- workspace.latest_build.id,
- new Date(workspace.latest_build.created_at),
- ),
+ const buildLogsQuery = useQuery({
+ ...buildLogs(workspace),
enabled: dialogProps.open,
});
const downloadFiles: DownloadFile[] = useMemo(() => {
const files: DownloadFile[] = [
{
name: `${workspace.name}-build-logs.txt`,
- blob: buildLogs.data
- ? new Blob([buildLogs.data.map((l) => l.output).join("\n")], {
+ blob: buildLogsQuery.data
+ ? new Blob([buildLogsQuery.data.map((l) => l.output).join("\n")], {
type: "text/plain",
})
: undefined,
@@ -71,7 +67,7 @@ export const DownloadLogsDialog: FC = ({
});
return files;
- }, [agentLogResults, agents, buildLogs.data, workspace.name]);
+ }, [agentLogResults, agents, buildLogsQuery.data, workspace.name]);
const isLoadingFiles = downloadFiles.some((f) => f.blob === undefined);
const [isDownloading, setIsDownloading] = useState(false);
@@ -93,7 +89,7 @@ export const DownloadLogsDialog: FC = ({
}
});
const content = await zip.generateAsync({ type: "blob" });
- saveAs(content, `${workspace.name}-logs.zip`);
+ download(content, `${workspace.name}-logs.zip`);
dialogProps.onClose();
setTimeout(() => {
setIsDownloading(false);
From 11b528a6d699dc863c1210403bbd77d6d6721eb4 Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Tue, 4 Jun 2024 14:19:15 +0000
Subject: [PATCH 07/26] Add test to verify if download dialog is opening
---
.../WorkspaceActions.stories.tsx | 31 +++++++++++++++++++
1 file changed, 31 insertions(+)
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx
index ce03863b69c55..da36da305b956 100644
--- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx
@@ -1,4 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
+import { fireEvent, within } from "@storybook/test";
+import { buildLogsKey, agentLogsKey } from "api/queries/workspaces";
import * as Mocks from "testHelpers/entities";
import { WorkspaceActions } from "./WorkspaceActions";
@@ -140,3 +142,32 @@ export const CancelHiddenForUser: Story = {
isOwner: false,
},
};
+
+export const OpenDownloadLogs: Story = {
+ args: {
+ workspace: Mocks.MockWorkspace,
+ },
+ parameters: {
+ queries: [
+ {
+ key: buildLogsKey(Mocks.MockWorkspace.id),
+ data: generateLogs(200),
+ },
+ {
+ key: agentLogsKey(Mocks.MockWorkspace.id, Mocks.MockWorkspaceAgent.id),
+ data: generateLogs(400),
+ },
+ ],
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await fireEvent.click(canvas.getByRole("button", { name: "More options" }));
+ await fireEvent.click(canvas.getByText("Download logs", { exact: false }));
+ },
+};
+
+function generateLogs(count: number) {
+ return Array.from({ length: count }, (_, i) => ({
+ output: `log ${i + 1}`,
+ }));
+}
From efcee2ba7bae57a968c2ef283112961c13656f9d Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Tue, 4 Jun 2024 14:32:22 +0000
Subject: [PATCH 08/26] Add tests to download agent logs
---
.../DownloadAgentLogsButton.stories.tsx | 42 +++++++++++++++++++
.../resources/DownloadAgentLogsButton.tsx | 2 +-
.../DownloadLogsDialog.stories.tsx | 4 +-
.../WorkspaceActions.stories.tsx | 6 +--
4 files changed, 48 insertions(+), 6 deletions(-)
create mode 100644 site/src/modules/resources/DownloadAgentLogsButton.stories.tsx
diff --git a/site/src/modules/resources/DownloadAgentLogsButton.stories.tsx b/site/src/modules/resources/DownloadAgentLogsButton.stories.tsx
new file mode 100644
index 0000000000000..ede516a37a8c8
--- /dev/null
+++ b/site/src/modules/resources/DownloadAgentLogsButton.stories.tsx
@@ -0,0 +1,42 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { waitFor, within, userEvent, expect, fn } from "@storybook/test";
+import { MockWorkspaceAgent } from "testHelpers/entities";
+import { DownloadAgentLogsButton } from "./DownloadAgentLogsButton";
+
+const meta: Meta = {
+ title: "modules/resources/DownloadAgentLogsButton",
+ component: DownloadAgentLogsButton,
+ args: {
+ agent: MockWorkspaceAgent,
+ logs: generateLogs(10),
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const ClickOnDownload: Story = {
+ args: {
+ onDownload: fn(),
+ },
+ play: async ({ canvasElement, args }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByRole("button", { name: "Download" }));
+ await waitFor(() =>
+ expect(args.onDownload).toHaveBeenCalledWith(
+ expect.anything(),
+ `${MockWorkspaceAgent.name}-logs.txt`,
+ ),
+ );
+ const blob: Blob = (args.onDownload as jest.Mock).mock.calls[0][0];
+ await expect(blob.type).toEqual("text/plain");
+ },
+};
+
+function generateLogs(count: number) {
+ return Array.from({ length: count }, (_, i) => ({
+ output: `log line ${i}`,
+ }));
+}
diff --git a/site/src/modules/resources/DownloadAgentLogsButton.tsx b/site/src/modules/resources/DownloadAgentLogsButton.tsx
index 0567918ad3ba9..7c7c09289ba99 100644
--- a/site/src/modules/resources/DownloadAgentLogsButton.tsx
+++ b/site/src/modules/resources/DownloadAgentLogsButton.tsx
@@ -7,7 +7,7 @@ import type { LineWithID } from "./AgentLogs/AgentLogLine";
type DownloadAgentLogsButtonProps = {
agent: Pick;
- logs: LineWithID[] | undefined;
+ logs: Pick[] | undefined;
onDownload?: (file: Blob, filename: string) => void;
};
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx
index 04c4429c21509..de6c026c66c21 100644
--- a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
-import { expect, fireEvent, fn, waitFor, within } from "@storybook/test";
+import { expect, userEvent, fn, waitFor, within } from "@storybook/test";
import { agentLogsKey, buildLogsKey } from "api/queries/workspaces";
import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
import { DownloadLogsDialog } from "./DownloadLogsDialog";
@@ -52,7 +52,7 @@ export const DownloadLogs: Story = {
},
play: async ({ args }) => {
const screen = within(document.body);
- await fireEvent.click(screen.getByRole("button", { name: "Download" }));
+ await userEvent.click(screen.getByRole("button", { name: "Download" }));
await waitFor(() =>
expect(args.download).toHaveBeenCalledWith(
expect.anything(),
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx
index da36da305b956..703374087bb77 100644
--- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
-import { fireEvent, within } from "@storybook/test";
+import { userEvent, within } from "@storybook/test";
import { buildLogsKey, agentLogsKey } from "api/queries/workspaces";
import * as Mocks from "testHelpers/entities";
import { WorkspaceActions } from "./WorkspaceActions";
@@ -161,8 +161,8 @@ export const OpenDownloadLogs: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
- await fireEvent.click(canvas.getByRole("button", { name: "More options" }));
- await fireEvent.click(canvas.getByText("Download logs", { exact: false }));
+ await userEvent.click(canvas.getByRole("button", { name: "More options" }));
+ await userEvent.click(canvas.getByText("Download logs", { exact: false }));
},
};
From 895e9424ae436836150280d5d786fb50ba9a3410 Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Tue, 4 Jun 2024 14:36:11 +0000
Subject: [PATCH 09/26] Fix package.json
---
package.json | 5 +--
pnpm-lock.yaml | 86 ---------------------------------------------
site/package.json | 3 ++
site/pnpm-lock.yaml | 50 ++++++++++++++++++++++----
4 files changed, 48 insertions(+), 96 deletions(-)
diff --git a/package.json b/package.json
index 451b677df4588..b290e5990874d 100644
--- a/package.json
+++ b/package.json
@@ -7,13 +7,10 @@
"storybook": "pnpm run -C site/ storybook"
},
"devDependencies": {
- "@types/file-saver": "^2.0.7",
"prettier": "3.0.0"
},
"dependencies": {
- "exec": "^0.2.1",
- "file-saver": "^2.0.5",
- "jszip": "^3.10.1"
+ "exec": "^0.2.1"
},
"packageManager": "pnpm@8.14.0+sha1.bb42032ff80dba5f9245bc1b03470d2fa0b7fb2f"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f4a45c1f288d8..e5e4d2584e40f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,108 +8,22 @@ dependencies:
exec:
specifier: ^0.2.1
version: 0.2.1
- file-saver:
- specifier: ^2.0.5
- version: 2.0.5
- jszip:
- specifier: ^3.10.1
- version: 3.10.1
devDependencies:
- '@types/file-saver':
- specifier: ^2.0.7
- version: 2.0.7
prettier:
specifier: 3.0.0
version: 3.0.0
packages:
- /@types/file-saver@2.0.7:
- resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==}
- dev: true
-
- /core-util-is@1.0.3:
- resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
- dev: false
-
/exec@0.2.1:
resolution: {integrity: sha512-lE5ZlJgRYh+rmwidatL2AqRA/U9IBoCpKlLriBmnfUIrV/Rj4oLjb63qZ57iBCHWi5j9IjLt5wOWkFYPiTfYAg==}
engines: {node: '>= v0.9.1'}
deprecated: deprecated in favor of builtin child_process.execFile
dev: false
- /file-saver@2.0.5:
- resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==}
- dev: false
-
- /immediate@3.0.6:
- resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
- dev: false
-
- /inherits@2.0.4:
- resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
- dev: false
-
- /isarray@1.0.0:
- resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
- dev: false
-
- /jszip@3.10.1:
- resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
- dependencies:
- lie: 3.3.0
- pako: 1.0.11
- readable-stream: 2.3.8
- setimmediate: 1.0.5
- dev: false
-
- /lie@3.3.0:
- resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
- dependencies:
- immediate: 3.0.6
- dev: false
-
- /pako@1.0.11:
- resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
- dev: false
-
/prettier@3.0.0:
resolution: {integrity: sha512-zBf5eHpwHOGPC47h0zrPyNn+eAEIdEzfywMoYn2XPi0P44Zp0tSq64rq0xAREh4auw2cJZHo9QUob+NqCQky4g==}
engines: {node: '>=14'}
hasBin: true
dev: true
-
- /process-nextick-args@2.0.1:
- resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
- dev: false
-
- /readable-stream@2.3.8:
- resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
- dependencies:
- core-util-is: 1.0.3
- inherits: 2.0.4
- isarray: 1.0.0
- process-nextick-args: 2.0.1
- safe-buffer: 5.1.2
- string_decoder: 1.1.1
- util-deprecate: 1.0.2
- dev: false
-
- /safe-buffer@5.1.2:
- resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
- dev: false
-
- /setimmediate@1.0.5:
- resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
- dev: false
-
- /string_decoder@1.1.1:
- resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
- dependencies:
- safe-buffer: 5.1.2
- dev: false
-
- /util-deprecate@1.0.2:
- resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
- dev: false
diff --git a/site/package.json b/site/package.json
index 2aa4c6b047c0b..3640558a824f1 100644
--- a/site/package.json
+++ b/site/package.json
@@ -45,6 +45,7 @@
"@mui/system": "5.14.0",
"@mui/utils": "5.14.11",
"@tanstack/react-query-devtools": "4.35.3",
+ "@types/file-saver": "2.0.7",
"ansi-to-html": "0.7.2",
"axios": "1.6.0",
"canvas": "2.11.0",
@@ -58,8 +59,10 @@
"date-fns": "2.30.0",
"dayjs": "1.11.4",
"emoji-mart": "5.4.0",
+ "file-saver": "2.0.5",
"formik": "2.4.1",
"front-matter": "4.0.2",
+ "jszip": "3.10.1",
"lodash": "4.17.21",
"monaco-editor": "0.44.0",
"pretty-bytes": "6.1.0",
diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml
index f454cd2ee9a22..bd0fb5864057c 100644
--- a/site/pnpm-lock.yaml
+++ b/site/pnpm-lock.yaml
@@ -54,6 +54,9 @@ dependencies:
'@tanstack/react-query-devtools':
specifier: 4.35.3
version: 4.35.3(@tanstack/react-query@4.35.3)(react-dom@18.2.0)(react@18.2.0)
+ '@types/file-saver':
+ specifier: 2.0.7
+ version: 2.0.7
ansi-to-html:
specifier: 0.7.2
version: 0.7.2
@@ -93,12 +96,18 @@ dependencies:
emoji-mart:
specifier: 5.4.0
version: 5.4.0
+ file-saver:
+ specifier: 2.0.5
+ version: 2.0.5
formik:
specifier: 2.4.1
version: 2.4.1(react@18.2.0)
front-matter:
specifier: 4.0.2
version: 4.0.2
+ jszip:
+ specifier: 3.10.1
+ version: 3.10.1
lodash:
specifier: 4.17.21
version: 4.17.21
@@ -4781,6 +4790,10 @@ packages:
'@types/serve-static': 1.15.2
dev: true
+ /@types/file-saver@2.0.7:
+ resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==}
+ dev: false
+
/@types/find-cache-dir@3.2.1:
resolution: {integrity: sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw==}
dev: true
@@ -6444,7 +6457,6 @@ packages:
/core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
- dev: true
/cosmiconfig@7.1.0:
resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
@@ -7682,6 +7694,10 @@ packages:
flat-cache: 3.1.1
dev: true
+ /file-saver@2.0.5:
+ resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==}
+ dev: false
+
/file-system-cache@2.3.0:
resolution: {integrity: sha512-l4DMNdsIPsVnKrgEXbJwDJsA5mB8rGwHYERMgqQx/xAUtChPJMre1bXBzDEqqVbWv9AIbFezXMxeEkZDSrXUOQ==}
dependencies:
@@ -8334,6 +8350,10 @@ packages:
engines: {node: '>= 4'}
dev: true
+ /immediate@3.0.6:
+ resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
+ dev: false
+
/import-fresh@3.3.0:
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
engines: {node: '>=6'}
@@ -8696,7 +8716,6 @@ packages:
/isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
- dev: true
/isarray@2.0.5:
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
@@ -9479,6 +9498,15 @@ packages:
object.values: 1.1.7
dev: true
+ /jszip@3.10.1:
+ resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
+ dependencies:
+ lie: 3.3.0
+ pako: 1.0.11
+ readable-stream: 2.3.8
+ setimmediate: 1.0.5
+ dev: false
+
/keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
dependencies:
@@ -9527,6 +9555,12 @@ packages:
type-check: 0.4.0
dev: true
+ /lie@3.3.0:
+ resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
+ dependencies:
+ immediate: 3.0.6
+ dev: false
+
/lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -10573,6 +10607,10 @@ packages:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
dev: true
+ /pako@1.0.11:
+ resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+ dev: false
+
/parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -10814,7 +10852,6 @@ packages:
/process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
- dev: true
/process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
@@ -11310,7 +11347,6 @@ packages:
safe-buffer: 5.1.2
string_decoder: 1.1.1
util-deprecate: 1.0.2
- dev: true
/readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
@@ -11625,7 +11661,6 @@ packages:
/safe-buffer@5.1.2:
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
- dev: true
/safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -11726,6 +11761,10 @@ packages:
engines: {node: '>=6.9'}
dev: false
+ /setimmediate@1.0.5:
+ resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
+ dev: false
+
/setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
dev: true
@@ -12064,7 +12103,6 @@ packages:
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
dependencies:
safe-buffer: 5.1.2
- dev: true
/string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
From 9ee7fd402cf21d828cf69cd09d7a163a30ab4a6f Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Tue, 4 Jun 2024 19:44:45 +0000
Subject: [PATCH 10/26] Commit Asher suggestions
---
site/src/api/api.ts | 3 +--
site/src/api/queries/workspaces.ts | 6 +-----
site/src/modules/resources/AgentRow.test.tsx | 2 +-
.../modules/resources/DownloadAgentLogsButton.stories.tsx | 4 +++-
site/src/modules/resources/DownloadAgentLogsButton.tsx | 2 +-
5 files changed, 7 insertions(+), 10 deletions(-)
diff --git a/site/src/api/api.ts b/site/src/api/api.ts
index 7e8829201dc3a..2a1057ef04b4a 100644
--- a/site/src/api/api.ts
+++ b/site/src/api/api.ts
@@ -1142,10 +1142,9 @@ class ApiMethods {
getWorkspaceBuildLogs = async (
buildId: string,
- before: Date,
): Promise => {
const response = await this.axios.get(
- `/api/v2/workspacebuilds/${buildId}/logs?before=${before.getTime()}`,
+ `/api/v2/workspacebuilds/${buildId}/logs`,
);
return response.data;
diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts
index b138dc989cc2c..f4e84ef942f15 100644
--- a/site/src/api/queries/workspaces.ts
+++ b/site/src/api/queries/workspaces.ts
@@ -293,11 +293,7 @@ export const buildLogsKey = (workspaceId: string) => [
export const buildLogs = (workspace: Workspace) => {
return {
queryKey: buildLogsKey(workspace.id),
- queryFn: () =>
- API.getWorkspaceBuildLogs(
- workspace.latest_build.id,
- new Date(workspace.latest_build.created_at),
- ),
+ queryFn: () => API.getWorkspaceBuildLogs(workspace.latest_build.id),
};
};
diff --git a/site/src/modules/resources/AgentRow.test.tsx b/site/src/modules/resources/AgentRow.test.tsx
index 5e83e22f91c94..0ad222fc09740 100644
--- a/site/src/modules/resources/AgentRow.test.tsx
+++ b/site/src/modules/resources/AgentRow.test.tsx
@@ -8,7 +8,7 @@ import {
renderWithAuth,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
-import { type AgentRowProps } from "./AgentRow";
+import type { AgentRowProps } from "./AgentRow";
import { AgentRow } from "./AgentRow";
import { DisplayAppNameMap } from "./AppLink/AppLink";
diff --git a/site/src/modules/resources/DownloadAgentLogsButton.stories.tsx b/site/src/modules/resources/DownloadAgentLogsButton.stories.tsx
index ede516a37a8c8..1c72f7a6d969a 100644
--- a/site/src/modules/resources/DownloadAgentLogsButton.stories.tsx
+++ b/site/src/modules/resources/DownloadAgentLogsButton.stories.tsx
@@ -23,7 +23,9 @@ export const ClickOnDownload: Story = {
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
- await userEvent.click(canvas.getByRole("button", { name: "Download" }));
+ await userEvent.click(
+ canvas.getByRole("button", { name: "Download logs" }),
+ );
await waitFor(() =>
expect(args.onDownload).toHaveBeenCalledWith(
expect.anything(),
diff --git a/site/src/modules/resources/DownloadAgentLogsButton.tsx b/site/src/modules/resources/DownloadAgentLogsButton.tsx
index 7c7c09289ba99..3bef468294f62 100644
--- a/site/src/modules/resources/DownloadAgentLogsButton.tsx
+++ b/site/src/modules/resources/DownloadAgentLogsButton.tsx
@@ -34,7 +34,7 @@ export const DownloadAgentLogsButton: FC = ({
onDownload(file, `${agent.name}-logs.txt`);
}}
>
- Download
+ Download logs
);
};
From a71529a0789d28d4dca59392c25bcd9bb8285949 Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Wed, 5 Jun 2024 14:23:26 +0000
Subject: [PATCH 11/26] Fix open download logs story
---
.../WorkspaceActions/WorkspaceActions.stories.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx
index 703374087bb77..b87a8ba8764c1 100644
--- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
-import { userEvent, within } from "@storybook/test";
+import { userEvent, within, expect } from "@storybook/test";
import { buildLogsKey, agentLogsKey } from "api/queries/workspaces";
import * as Mocks from "testHelpers/entities";
import { WorkspaceActions } from "./WorkspaceActions";
@@ -163,6 +163,8 @@ export const OpenDownloadLogs: Story = {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "More options" }));
await userEvent.click(canvas.getByText("Download logs", { exact: false }));
+ const screen = within(document.body);
+ await expect(screen.getByTestId("dialog")).toBeInTheDocument();
},
};
From f82a4e46963edab03bb71b63c01aa009e699933a Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Wed, 5 Jun 2024 14:25:28 +0000
Subject: [PATCH 12/26] Fix dropdown arrow on light theme
---
site/src/components/DropdownArrow/DropdownArrow.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/site/src/components/DropdownArrow/DropdownArrow.tsx b/site/src/components/DropdownArrow/DropdownArrow.tsx
index e0d79d6b12305..dc26a8d2da3f6 100644
--- a/site/src/components/DropdownArrow/DropdownArrow.tsx
+++ b/site/src/components/DropdownArrow/DropdownArrow.tsx
@@ -26,11 +26,11 @@ export const DropdownArrow: FC = ({
};
const styles = {
- base: (theme) => ({
- color: theme.palette.primary.contrastText,
+ base: {
+ color: "currentcolor",
width: 16,
height: 16,
- }),
+ },
withMargin: {
marginLeft: 8,
From 967fc0bc880e24af5445d92376827539c649a9ca Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Wed, 5 Jun 2024 16:00:26 +0000
Subject: [PATCH 13/26] Enable agent logs only when showing
---
site/src/modules/resources/AgentRow.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx
index 6a6ef22695b20..5f5780cf67d4c 100644
--- a/site/src/modules/resources/AgentRow.tsx
+++ b/site/src/modules/resources/AgentRow.tsx
@@ -97,6 +97,7 @@ export const AgentRow: FC = ({
);
const agentLogs = useAgentLogs(agent.id, {
initialData: process.env.STORYBOOK ? storybookLogs || [] : undefined,
+ enabled: showLogs,
});
const logListRef = useRef(null);
const logListDivRef = useRef(null);
From 73af195c8ac0738614486f6edd4a1465a3053246 Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Wed, 5 Jun 2024 16:11:22 +0000
Subject: [PATCH 14/26] Use web socket decorator and remove specific props for
storybook
---
.../modules/resources/AgentLogs/AgentLogs.tsx | 5 +--
.../modules/resources/AgentRow.stories.tsx | 38 ++++++++++---------
site/src/modules/resources/AgentRow.tsx | 8 +---
3 files changed, 24 insertions(+), 27 deletions(-)
diff --git a/site/src/modules/resources/AgentLogs/AgentLogs.tsx b/site/src/modules/resources/AgentLogs/AgentLogs.tsx
index 407e3c12fe9b5..62d674bad84c9 100644
--- a/site/src/modules/resources/AgentLogs/AgentLogs.tsx
+++ b/site/src/modules/resources/AgentLogs/AgentLogs.tsx
@@ -181,11 +181,10 @@ export const AgentLogs = forwardRef(
export const useAgentLogs = (
agentId: string,
- options?: { enabled?: boolean; initialData?: LineWithID[] },
+ options?: { enabled?: boolean },
) => {
- const initialData = options?.initialData;
const enabled = options?.enabled === undefined ? true : options.enabled;
- const [logs, setLogs] = useState(initialData);
+ const [logs, setLogs] = useState();
useEffect(() => {
if (!enabled) {
diff --git a/site/src/modules/resources/AgentRow.stories.tsx b/site/src/modules/resources/AgentRow.stories.tsx
index c4c4149b7279c..9bcaa088b7ade 100644
--- a/site/src/modules/resources/AgentRow.stories.tsx
+++ b/site/src/modules/resources/AgentRow.stories.tsx
@@ -2,8 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react";
import { ProxyContext, getPreferredProxy } from "contexts/ProxyContext";
import { chromatic } from "testHelpers/chromatic";
import * as M from "testHelpers/entities";
-import { withDashboardProvider } from "testHelpers/storybook";
-import type { LineWithID } from "./AgentLogs/AgentLogLine";
+import { withDashboardProvider, withWebSocket } from "testHelpers/storybook";
import { AgentRow } from "./AgentRow";
const defaultAgentMetadata = [
@@ -69,7 +68,7 @@ const defaultAgentMetadata = [
},
];
-const storybookLogs: LineWithID[] = [
+const logs = [
"\x1b[91mCloning Git repository...",
"\x1b[2;37;41mStarting Docker Daemon...",
"\x1b[1;95mAdding some 🧙magic🧙...",
@@ -79,28 +78,17 @@ const storybookLogs: LineWithID[] = [
id: index,
level: "info",
output: line,
- time: "",
- sourceId: M.MockWorkspaceAgentLogSource.id,
+ source_id: M.MockWorkspaceAgentLogSource.id,
+ created_at: new Date().toISOString(),
}));
const meta: Meta = {
title: "components/AgentRow",
- parameters: {
- chromatic,
- queries: [
- {
- key: ["portForward", M.MockWorkspaceAgent.id],
- data: M.MockListeningPortsResponse,
- },
- ],
- },
-
component: AgentRow,
args: {
- storybookLogs,
agent: {
...M.MockWorkspaceAgent,
- logs_length: storybookLogs.length,
+ logs_length: logs.length,
},
workspace: M.MockWorkspace,
showApps: true,
@@ -130,7 +118,23 @@ const meta: Meta = {
),
withDashboardProvider,
+ withWebSocket,
],
+ parameters: {
+ chromatic,
+ queries: [
+ {
+ key: ["portForward", M.MockWorkspaceAgent.id],
+ data: M.MockListeningPortsResponse,
+ },
+ ],
+ webSocket: [
+ {
+ event: "message",
+ data: JSON.stringify(logs),
+ },
+ ],
+ },
};
export default meta;
diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx
index 5f5780cf67d4c..49265e189250e 100644
--- a/site/src/modules/resources/AgentRow.tsx
+++ b/site/src/modules/resources/AgentRow.tsx
@@ -26,10 +26,7 @@ import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
import { Stack } from "components/Stack/Stack";
import { useProxy } from "contexts/ProxyContext";
import { AgentLatency } from "./AgentLatency";
-import {
- AGENT_LOG_LINE_HEIGHT,
- type LineWithID,
-} from "./AgentLogs/AgentLogLine";
+import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine";
import { AgentLogs, useAgentLogs } from "./AgentLogs/AgentLogs";
import { AgentMetadata } from "./AgentMetadata";
import { AgentStatus } from "./AgentStatus";
@@ -54,7 +51,6 @@ export interface AgentRowProps {
serverAPIVersion: string;
onUpdateAgent: () => void;
template: Template;
- storybookLogs?: LineWithID[];
storybookAgentMetadata?: WorkspaceAgentMetadata[];
}
@@ -71,7 +67,6 @@ export const AgentRow: FC = ({
onUpdateAgent,
storybookAgentMetadata,
sshPrefix,
- storybookLogs,
}) => {
// XRay integration
const xrayScanQuery = useQuery(
@@ -96,7 +91,6 @@ export const AgentRow: FC = ({
hasStartupFeatures,
);
const agentLogs = useAgentLogs(agent.id, {
- initialData: process.env.STORYBOOK ? storybookLogs || [] : undefined,
enabled: showLogs,
});
const logListRef = useRef(null);
From f145ead21227c0b19629263bd8052343daebbe76 Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Wed, 5 Jun 2024 17:38:18 +0000
Subject: [PATCH 15/26] Refactor useAgentLogs to optimize loading
---
.../modules/resources/AgentLogs/AgentLogs.tsx | 66 +----------------
.../resources/AgentLogs/useAgentLogs.ts | 70 +++++++++++++++++++
site/src/modules/resources/AgentRow.tsx | 24 +++++--
.../WorkspaceBuildPageView.tsx | 23 ++++--
4 files changed, 106 insertions(+), 77 deletions(-)
create mode 100644 site/src/modules/resources/AgentLogs/useAgentLogs.ts
diff --git a/site/src/modules/resources/AgentLogs/AgentLogs.tsx b/site/src/modules/resources/AgentLogs/AgentLogs.tsx
index 62d674bad84c9..518d4315bb7e3 100644
--- a/site/src/modules/resources/AgentLogs/AgentLogs.tsx
+++ b/site/src/modules/resources/AgentLogs/AgentLogs.tsx
@@ -1,14 +1,7 @@
import type { Interpolation, Theme } from "@emotion/react";
import Tooltip from "@mui/material/Tooltip";
-import {
- type ComponentProps,
- forwardRef,
- useEffect,
- useMemo,
- useState,
-} from "react";
+import { type ComponentProps, forwardRef, useMemo } from "react";
import { FixedSizeList as List } from "react-window";
-import { watchWorkspaceAgentLogs } from "api/api";
import type { WorkspaceAgentLogSource } from "api/typesGenerated";
import {
AGENT_LOG_LINE_HEIGHT,
@@ -179,63 +172,6 @@ export const AgentLogs = forwardRef(
},
);
-export const useAgentLogs = (
- agentId: string,
- options?: { enabled?: boolean },
-) => {
- const enabled = options?.enabled === undefined ? true : options.enabled;
- const [logs, setLogs] = useState();
-
- useEffect(() => {
- if (!enabled) {
- setLogs([]);
- return;
- }
-
- const socket = watchWorkspaceAgentLogs(agentId, {
- // Get all logs
- after: 0,
- onMessage: (logs) => {
- // Prevent new logs getting added when a connection is not open
- if (socket.readyState !== WebSocket.OPEN) {
- return;
- }
-
- setLogs((previousLogs) => {
- const newLogs: LineWithID[] = logs.map((log) => ({
- id: log.id,
- level: log.level || "info",
- output: log.output,
- time: log.created_at,
- sourceId: log.source_id,
- }));
-
- if (!previousLogs) {
- return newLogs;
- }
-
- return [...previousLogs, ...newLogs];
- });
- },
- onError: (error) => {
- // For some reason Firefox and Safari throw an error when a websocket
- // connection is close in the middle of a message and because of that we
- // can't safely show to the users an error message since most of the
- // time they are just internal stuff. This does not happen to Chrome at
- // all and I tried to find better way to "soft close" a WS connection on
- // those browsers without success.
- console.error(error);
- },
- });
-
- return () => {
- socket.close();
- };
- }, [agentId, enabled]);
-
- return logs;
-};
-
// These colors were picked at random. Feel free
// to add more, adjust, or change! Users will not
// depend on these colors.
diff --git a/site/src/modules/resources/AgentLogs/useAgentLogs.ts b/site/src/modules/resources/AgentLogs/useAgentLogs.ts
new file mode 100644
index 0000000000000..81d3937aae745
--- /dev/null
+++ b/site/src/modules/resources/AgentLogs/useAgentLogs.ts
@@ -0,0 +1,70 @@
+import { useEffect, useRef } from "react";
+import { useQuery, useQueryClient } from "react-query";
+import { watchWorkspaceAgentLogs } from "api/api";
+import { agentLogs } from "api/queries/workspaces";
+import type {
+ WorkspaceAgentLifecycle,
+ WorkspaceAgentLog,
+} from "api/typesGenerated";
+import { useEffectEvent } from "hooks/hookPolyfills";
+
+export const useAgentLogs = (
+ workspaceId: string,
+ agentId: string,
+ agentLifeCycleState: WorkspaceAgentLifecycle,
+ options?: { enabled?: boolean },
+) => {
+ const queryClient = useQueryClient();
+ const queryOptions = agentLogs(workspaceId, agentId);
+ const query = useQuery({
+ ...queryOptions,
+ enabled: options?.enabled,
+ });
+ const logs = query.data;
+
+ const lastQueriedLogId = useRef(0);
+ useEffect(() => {
+ if (logs && lastQueriedLogId.current === 0) {
+ lastQueriedLogId.current = logs[logs.length - 1].id;
+ }
+ }, [logs]);
+
+ const addLogs = useEffectEvent((newLogs: WorkspaceAgentLog[]) => {
+ queryClient.setQueryData(
+ queryOptions.queryKey,
+ (oldData: WorkspaceAgentLog[] = []) => [...oldData, ...newLogs],
+ );
+ });
+
+ useEffect(() => {
+ if (agentLifeCycleState !== "starting" || !query.isFetched) {
+ return;
+ }
+
+ const socket = watchWorkspaceAgentLogs(agentId, {
+ after: lastQueriedLogId.current,
+ onMessage: (newLogs) => {
+ // Prevent new logs getting added when a connection is not open
+ if (socket.readyState !== WebSocket.OPEN) {
+ return;
+ }
+ addLogs(newLogs);
+ },
+ onError: (error) => {
+ // For some reason Firefox and Safari throw an error when a websocket
+ // connection is close in the middle of a message and because of that we
+ // can't safely show to the users an error message since most of the
+ // time they are just internal stuff. This does not happen to Chrome at
+ // all and I tried to find better way to "soft close" a WS connection on
+ // those browsers without success.
+ console.error(error);
+ },
+ });
+
+ return () => {
+ socket.close();
+ };
+ }, [addLogs, agentId, agentLifeCycleState, query.isFetched]);
+
+ return logs;
+};
diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx
index 49265e189250e..43847c53108f3 100644
--- a/site/src/modules/resources/AgentRow.tsx
+++ b/site/src/modules/resources/AgentRow.tsx
@@ -27,7 +27,8 @@ import { Stack } from "components/Stack/Stack";
import { useProxy } from "contexts/ProxyContext";
import { AgentLatency } from "./AgentLatency";
import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine";
-import { AgentLogs, useAgentLogs } from "./AgentLogs/AgentLogs";
+import { AgentLogs } from "./AgentLogs/AgentLogs";
+import { useAgentLogs } from "./AgentLogs/useAgentLogs";
import { AgentMetadata } from "./AgentMetadata";
import { AgentStatus } from "./AgentStatus";
import { AgentVersion } from "./AgentVersion";
@@ -90,9 +91,12 @@ export const AgentRow: FC = ({
["starting", "start_timeout"].includes(agent.lifecycle_state) &&
hasStartupFeatures,
);
- const agentLogs = useAgentLogs(agent.id, {
- enabled: showLogs,
- });
+ const agentLogs = useAgentLogs(
+ workspace.id,
+ agent.id,
+ agent.lifecycle_state,
+ { enabled: showLogs },
+ );
const logListRef = useRef(null);
const logListDivRef = useRef(null);
const startupLogs = useMemo(() => {
@@ -104,8 +108,8 @@ export const AgentRow: FC = ({
id: -1,
level: "error",
output: "Startup logs exceeded the max size of 1MB!",
- time: new Date().toISOString(),
- sourceId: "",
+ created_at: new Date().toISOString(),
+ source_id: "",
});
}
return logs;
@@ -286,7 +290,13 @@ export const AgentRow: FC = ({
width={width}
css={styles.startupLogs}
onScroll={handleLogScroll}
- logs={startupLogs}
+ logs={startupLogs.map((l) => ({
+ id: l.id,
+ level: l.level,
+ output: l.output,
+ sourceId: l.source_id,
+ time: l.created_at,
+ }))}
sources={agent.log_sources}
/>
)}
diff --git a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx
index ed62c37ee29f6..7e185889aacc6 100644
--- a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx
+++ b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx
@@ -19,7 +19,8 @@ import { Stats, StatsItem } from "components/Stats/Stats";
import { TAB_PADDING_X, TabLink, Tabs, TabsList } from "components/Tabs/Tabs";
import { useSearchParamsKey } from "hooks/useSearchParamsKey";
import { DashboardFullPage } from "modules/dashboard/DashboardLayout";
-import { AgentLogs, useAgentLogs } from "modules/resources/AgentLogs/AgentLogs";
+import { AgentLogs } from "modules/resources/AgentLogs/AgentLogs";
+import { useAgentLogs } from "modules/resources/AgentLogs/useAgentLogs";
import {
WorkspaceBuildData,
WorkspaceBuildDataSkeleton,
@@ -193,7 +194,10 @@ export const WorkspaceBuildPageView: FC = ({
{tabState.value === "build" ? (
) : (
-
+
)}
@@ -222,8 +226,11 @@ const BuildLogsContent: FC<{ logs?: ProvisionerJobLog[] }> = ({ logs }) => {
);
};
-const AgentLogsContent: FC<{ agent: WorkspaceAgent }> = ({ agent }) => {
- const logs = useAgentLogs(agent.id);
+const AgentLogsContent: FC<{ workspaceId: string; agent: WorkspaceAgent }> = ({
+ agent,
+ workspaceId,
+}) => {
+ const logs = useAgentLogs(workspaceId, agent.id, agent.lifecycle_state);
if (!logs) {
return ;
@@ -232,7 +239,13 @@ const AgentLogsContent: FC<{ agent: WorkspaceAgent }> = ({ agent }) => {
return (
({
+ id: l.id,
+ output: l.output,
+ time: l.created_at,
+ level: l.level,
+ sourceId: l.source_id,
+ }))}
height={560}
width="100%"
/>
From 5baa0524a949999d13504d4554287bcd7b3c1866 Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Wed, 5 Jun 2024 17:50:22 +0000
Subject: [PATCH 16/26] Add prefetch back
---
site/src/modules/resources/AgentRow.tsx | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx
index 43847c53108f3..99f0c420b2510 100644
--- a/site/src/modules/resources/AgentRow.tsx
+++ b/site/src/modules/resources/AgentRow.tsx
@@ -91,12 +91,7 @@ export const AgentRow: FC = ({
["starting", "start_timeout"].includes(agent.lifecycle_state) &&
hasStartupFeatures,
);
- const agentLogs = useAgentLogs(
- workspace.id,
- agent.id,
- agent.lifecycle_state,
- { enabled: showLogs },
- );
+ const agentLogs = useAgentLogs(workspace.id, agent.id, agent.lifecycle_state);
const logListRef = useRef(null);
const logListDivRef = useRef(null);
const startupLogs = useMemo(() => {
From 40d2a658c44d86e7a7fdcbee54fdec8a3f6e01ca Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Wed, 5 Jun 2024 18:15:37 +0000
Subject: [PATCH 17/26] Only fetch logs when clicking on download
---
site/src/modules/resources/AgentRow.tsx | 9 ++-
.../DownloadAgentLogsButton.stories.tsx | 26 ++++++--
.../resources/DownloadAgentLogsButton.tsx | 62 +++++++++++++------
3 files changed, 70 insertions(+), 27 deletions(-)
diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx
index 99f0c420b2510..7c588c884a881 100644
--- a/site/src/modules/resources/AgentRow.tsx
+++ b/site/src/modules/resources/AgentRow.tsx
@@ -91,7 +91,12 @@ export const AgentRow: FC = ({
["starting", "start_timeout"].includes(agent.lifecycle_state) &&
hasStartupFeatures,
);
- const agentLogs = useAgentLogs(workspace.id, agent.id, agent.lifecycle_state);
+ const agentLogs = useAgentLogs(
+ workspace.id,
+ agent.id,
+ agent.lifecycle_state,
+ { enabled: showLogs },
+ );
const logListRef = useRef(null);
const logListDivRef = useRef(null);
const startupLogs = useMemo(() => {
@@ -308,7 +313,7 @@ export const AgentRow: FC = ({
Logs
-
+
)}
diff --git a/site/src/modules/resources/DownloadAgentLogsButton.stories.tsx b/site/src/modules/resources/DownloadAgentLogsButton.stories.tsx
index 1c72f7a6d969a..712a950decf1a 100644
--- a/site/src/modules/resources/DownloadAgentLogsButton.stories.tsx
+++ b/site/src/modules/resources/DownloadAgentLogsButton.stories.tsx
@@ -1,14 +1,24 @@
import type { Meta, StoryObj } from "@storybook/react";
import { waitFor, within, userEvent, expect, fn } from "@storybook/test";
-import { MockWorkspaceAgent } from "testHelpers/entities";
+import { agentLogsKey } from "api/queries/workspaces";
+import type { WorkspaceAgentLog } from "api/typesGenerated";
+import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
import { DownloadAgentLogsButton } from "./DownloadAgentLogsButton";
const meta: Meta = {
title: "modules/resources/DownloadAgentLogsButton",
component: DownloadAgentLogsButton,
args: {
+ workspaceId: MockWorkspace.id,
agent: MockWorkspaceAgent,
- logs: generateLogs(10),
+ },
+ parameters: {
+ queries: [
+ {
+ key: agentLogsKey(MockWorkspace.id, MockWorkspaceAgent.id),
+ data: generateLogs(5),
+ },
+ ],
},
};
@@ -19,7 +29,7 @@ export const Default: Story = {};
export const ClickOnDownload: Story = {
args: {
- onDownload: fn(),
+ download: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
@@ -27,18 +37,22 @@ export const ClickOnDownload: Story = {
canvas.getByRole("button", { name: "Download logs" }),
);
await waitFor(() =>
- expect(args.onDownload).toHaveBeenCalledWith(
+ expect(args.download).toHaveBeenCalledWith(
expect.anything(),
`${MockWorkspaceAgent.name}-logs.txt`,
),
);
- const blob: Blob = (args.onDownload as jest.Mock).mock.calls[0][0];
+ const blob: Blob = (args.download as jest.Mock).mock.calls[0][0];
await expect(blob.type).toEqual("text/plain");
},
};
-function generateLogs(count: number) {
+function generateLogs(count: number): WorkspaceAgentLog[] {
return Array.from({ length: count }, (_, i) => ({
+ id: i,
output: `log line ${i}`,
+ created_at: new Date().toISOString(),
+ level: "info",
+ source_id: "",
}));
}
diff --git a/site/src/modules/resources/DownloadAgentLogsButton.tsx b/site/src/modules/resources/DownloadAgentLogsButton.tsx
index 3bef468294f62..a6e2d6b670365 100644
--- a/site/src/modules/resources/DownloadAgentLogsButton.tsx
+++ b/site/src/modules/resources/DownloadAgentLogsButton.tsx
@@ -1,40 +1,64 @@
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
import Button from "@mui/material/Button";
import { saveAs } from "file-saver";
-import type { FC } from "react";
-import type { WorkspaceAgent } from "api/typesGenerated";
-import type { LineWithID } from "./AgentLogs/AgentLogLine";
+import { useState, type FC } from "react";
+import { useQueryClient } from "react-query";
+import { agentLogs } from "api/queries/workspaces";
+import type { WorkspaceAgent, WorkspaceAgentLog } from "api/typesGenerated";
+import { displayError } from "components/GlobalSnackbar/utils";
type DownloadAgentLogsButtonProps = {
- agent: Pick;
- logs: Pick[] | undefined;
- onDownload?: (file: Blob, filename: string) => void;
+ workspaceId: string;
+ agent: Pick;
+ download?: (file: Blob, filename: string) => void;
};
export const DownloadAgentLogsButton: FC = ({
+ workspaceId,
agent,
- logs,
- onDownload = saveAs,
+ download = saveAs,
}) => {
- const isDisabled =
- agent.status !== "connected" || logs === undefined || logs.length === 0;
+ const queryClient = useQueryClient();
+ const isConnected = agent.status === "connected";
+ const [isDownloading, setIsDownloading] = useState(false);
+
+ const fetchLogs = async () => {
+ const queryOpts = agentLogs(workspaceId, agent.id);
+ let logs = queryClient.getQueryData(
+ queryOpts.queryKey,
+ );
+ if (!logs) {
+ logs = await queryClient.fetchQuery(queryOpts);
+ }
+ return logs;
+ };
return (
}
+ disabled={!isConnected || isDownloading}
variant="text"
size="small"
- startIcon={}
- onClick={() => {
- if (isDisabled) {
- return;
+ onClick={async () => {
+ try {
+ setIsDownloading(true);
+ const logs = await fetchLogs();
+ if (!logs) {
+ displayError("Failed to fetch logs");
+ setIsDownloading(false);
+ return;
+ }
+ const text = logs.map((l) => l.output).join("\n");
+ const file = new Blob([text], { type: "text/plain" });
+ download(file, `${agent.name}-logs.txt`);
+ setIsDownloading(false);
+ } catch (e) {
+ displayError("Failed to download logs");
+ setIsDownloading(false);
}
- const text = logs.map((l) => l.output).join("\n");
- const file = new Blob([text], { type: "text/plain" });
- onDownload(file, `${agent.name}-logs.txt`);
}}
>
- Download logs
+ {isDownloading ? "Downloading..." : "Download logs"}
);
};
From 35368ee06de19798e25bca43bb06bb9b5fd2c01a Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Wed, 5 Jun 2024 20:13:21 +0000
Subject: [PATCH 18/26] Add useAgentLogs test
---
site/src/api/queries/util.ts | 2 +-
site/src/api/queries/workspaces.ts | 2 +
.../resources/AgentLogs/useAgentLogs.test.tsx | 137 ++++++++++++++++++
.../resources/AgentLogs/useAgentLogs.ts | 6 +-
4 files changed, 145 insertions(+), 2 deletions(-)
create mode 100644 site/src/modules/resources/AgentLogs/useAgentLogs.test.tsx
diff --git a/site/src/api/queries/util.ts b/site/src/api/queries/util.ts
index 6043b984fab93..e34e20e556a01 100644
--- a/site/src/api/queries/util.ts
+++ b/site/src/api/queries/util.ts
@@ -1,7 +1,7 @@
import type { UseQueryOptions, QueryKey } from "react-query";
import type { MetadataState, MetadataValue } from "hooks/useEmbeddedMetadata";
-const disabledFetchOptions = {
+export const disabledFetchOptions = {
cacheTime: Infinity,
staleTime: Infinity,
refetchOnMount: false,
diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts
index f4e84ef942f15..33be420d0164d 100644
--- a/site/src/api/queries/workspaces.ts
+++ b/site/src/api/queries/workspaces.ts
@@ -14,6 +14,7 @@ import type {
WorkspacesRequest,
WorkspacesResponse,
} from "api/typesGenerated";
+import { disabledFetchOptions } from "./util";
import { workspaceBuildsKey } from "./workspaceBuilds";
export const workspaceByOwnerAndNameKey = (owner: string, name: string) => [
@@ -309,5 +310,6 @@ export const agentLogs = (workspaceId: string, agentId: string) => {
return {
queryKey: agentLogsKey(workspaceId, agentId),
queryFn: () => API.getWorkspaceAgentLogs(agentId),
+ ...disabledFetchOptions,
};
};
diff --git a/site/src/modules/resources/AgentLogs/useAgentLogs.test.tsx b/site/src/modules/resources/AgentLogs/useAgentLogs.test.tsx
new file mode 100644
index 0000000000000..bdfa9e2dcddb4
--- /dev/null
+++ b/site/src/modules/resources/AgentLogs/useAgentLogs.test.tsx
@@ -0,0 +1,137 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import WS from "jest-websocket-mock";
+import { type QueryClient, QueryClientProvider } from "react-query";
+import { API } from "api/api";
+import * as APIModule from "api/api";
+import { agentLogsKey } from "api/queries/workspaces";
+import type {
+ WorkspaceAgentLifecycle,
+ WorkspaceAgentLog,
+} from "api/typesGenerated";
+import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
+import { createTestQueryClient } from "testHelpers/renderHelpers";
+import { type UseAgentLogsOptions, useAgentLogs } from "./useAgentLogs";
+
+afterEach(() => {
+ WS.clean();
+});
+
+describe("useAgentLogs", () => {
+ it("should not fetch logs if disabled", async () => {
+ const queryClient = createTestQueryClient();
+ const fetchSpy = jest.spyOn(API, "getWorkspaceAgentLogs");
+ const wsSpy = jest.spyOn(APIModule, "watchWorkspaceAgentLogs");
+ renderUseAgentLogs(queryClient, "ready", { enabled: false });
+ expect(fetchSpy).not.toHaveBeenCalled();
+ expect(wsSpy).not.toHaveBeenCalled();
+ });
+
+ it("should return existing logs without network calls", async () => {
+ const queryClient = createTestQueryClient();
+ queryClient.setQueryData(
+ agentLogsKey(MockWorkspace.id, MockWorkspaceAgent.id),
+ generateLogs(5),
+ );
+ const fetchSpy = jest.spyOn(API, "getWorkspaceAgentLogs");
+ const wsSpy = jest.spyOn(APIModule, "watchWorkspaceAgentLogs");
+ const { result } = renderUseAgentLogs(queryClient, "ready");
+ await waitFor(() => {
+ expect(result.current).toHaveLength(5);
+ });
+ expect(fetchSpy).not.toHaveBeenCalled();
+ expect(wsSpy).not.toHaveBeenCalled();
+ });
+
+ it("should fetch logs when empty", async () => {
+ const queryClient = createTestQueryClient();
+ const fetchSpy = jest
+ .spyOn(API, "getWorkspaceAgentLogs")
+ .mockResolvedValueOnce(generateLogs(5));
+ const wsSpy = jest.spyOn(APIModule, "watchWorkspaceAgentLogs");
+ const { result } = renderUseAgentLogs(queryClient, "ready");
+ await waitFor(() => {
+ expect(result.current).toHaveLength(5);
+ });
+ expect(fetchSpy).toHaveBeenCalledWith(MockWorkspaceAgent.id);
+ expect(wsSpy).not.toHaveBeenCalled();
+ });
+
+ it("should fetch logs and connect to websocket when agent is starting", async () => {
+ const queryClient = createTestQueryClient();
+ const logs = generateLogs(5);
+ const fetchSpy = jest
+ .spyOn(API, "getWorkspaceAgentLogs")
+ .mockResolvedValueOnce(logs);
+ const wsSpy = jest.spyOn(APIModule, "watchWorkspaceAgentLogs");
+ new WS(
+ `ws://localhost/api/v2/workspaceagents/${
+ MockWorkspaceAgent.id
+ }/logs?follow&after=${logs[logs.length - 1].id}`,
+ );
+ const { result } = renderUseAgentLogs(queryClient, "starting");
+ await waitFor(() => {
+ expect(result.current).toHaveLength(5);
+ });
+ expect(fetchSpy).toHaveBeenCalledWith(MockWorkspaceAgent.id);
+ expect(wsSpy).toHaveBeenCalledWith(MockWorkspaceAgent.id, {
+ after: logs[logs.length - 1].id,
+ onMessage: expect.any(Function),
+ onError: expect.any(Function),
+ });
+ });
+
+ it("update logs from websocket messages", async () => {
+ const queryClient = createTestQueryClient();
+ const logs = generateLogs(5);
+ jest.spyOn(API, "getWorkspaceAgentLogs").mockResolvedValueOnce(logs);
+ const server = new WS(
+ `ws://localhost/api/v2/workspaceagents/${
+ MockWorkspaceAgent.id
+ }/logs?follow&after=${logs[logs.length - 1].id}`,
+ );
+ const { result } = renderUseAgentLogs(queryClient, "starting");
+ await waitFor(() => {
+ expect(result.current).toHaveLength(5);
+ });
+ await server.connected;
+ act(() => {
+ server.send(JSON.stringify(generateLogs(3)));
+ });
+ await waitFor(() => {
+ expect(result.current).toHaveLength(8);
+ });
+ });
+});
+
+function renderUseAgentLogs(
+ queryClient: QueryClient,
+ lifeCycleState: WorkspaceAgentLifecycle,
+ options?: UseAgentLogsOptions,
+) {
+ return renderHook(
+ () =>
+ useAgentLogs(
+ MockWorkspace.id,
+ MockWorkspaceAgent.id,
+ lifeCycleState,
+ options,
+ ),
+ {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ },
+ );
+}
+
+function generateLogs(count: number): WorkspaceAgentLog[] {
+ return Array.from({ length: count }, (_, i) => ({
+ id: i,
+ created_at: new Date().toISOString(),
+ level: "info",
+ output: `Log ${i}`,
+ source_id: "",
+ }));
+}
diff --git a/site/src/modules/resources/AgentLogs/useAgentLogs.ts b/site/src/modules/resources/AgentLogs/useAgentLogs.ts
index 81d3937aae745..8cb99ada2f6cc 100644
--- a/site/src/modules/resources/AgentLogs/useAgentLogs.ts
+++ b/site/src/modules/resources/AgentLogs/useAgentLogs.ts
@@ -8,11 +8,15 @@ import type {
} from "api/typesGenerated";
import { useEffectEvent } from "hooks/hookPolyfills";
+export type UseAgentLogsOptions = {
+ enabled?: boolean;
+};
+
export const useAgentLogs = (
workspaceId: string,
agentId: string,
agentLifeCycleState: WorkspaceAgentLifecycle,
- options?: { enabled?: boolean },
+ options?: UseAgentLogsOptions,
) => {
const queryClient = useQueryClient();
const queryOptions = agentLogs(workspaceId, agentId);
From 10b4b71151a5bee8e0314b145245e3c5f008d019 Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Thu, 6 Jun 2024 14:32:53 +0000
Subject: [PATCH 19/26] Use Michael refactoring of useAgentLogs
---
.../resources/AgentLogs/useAgentLogs.test.tsx | 62 ++++++++-------
.../resources/AgentLogs/useAgentLogs.ts | 75 ++++++++++++-------
2 files changed, 80 insertions(+), 57 deletions(-)
diff --git a/site/src/modules/resources/AgentLogs/useAgentLogs.test.tsx b/site/src/modules/resources/AgentLogs/useAgentLogs.test.tsx
index bdfa9e2dcddb4..5323a8bf57f26 100644
--- a/site/src/modules/resources/AgentLogs/useAgentLogs.test.tsx
+++ b/site/src/modules/resources/AgentLogs/useAgentLogs.test.tsx
@@ -4,10 +4,7 @@ import { type QueryClient, QueryClientProvider } from "react-query";
import { API } from "api/api";
import * as APIModule from "api/api";
import { agentLogsKey } from "api/queries/workspaces";
-import type {
- WorkspaceAgentLifecycle,
- WorkspaceAgentLog,
-} from "api/typesGenerated";
+import type { WorkspaceAgentLog } from "api/typesGenerated";
import { MockWorkspace, MockWorkspaceAgent } from "testHelpers/entities";
import { createTestQueryClient } from "testHelpers/renderHelpers";
import { type UseAgentLogsOptions, useAgentLogs } from "./useAgentLogs";
@@ -21,7 +18,12 @@ describe("useAgentLogs", () => {
const queryClient = createTestQueryClient();
const fetchSpy = jest.spyOn(API, "getWorkspaceAgentLogs");
const wsSpy = jest.spyOn(APIModule, "watchWorkspaceAgentLogs");
- renderUseAgentLogs(queryClient, "ready", { enabled: false });
+ renderUseAgentLogs(queryClient, {
+ workspaceId: MockWorkspace.id,
+ agentId: MockWorkspaceAgent.id,
+ agentLifeCycleState: "ready",
+ enabled: false,
+ });
expect(fetchSpy).not.toHaveBeenCalled();
expect(wsSpy).not.toHaveBeenCalled();
});
@@ -34,7 +36,11 @@ describe("useAgentLogs", () => {
);
const fetchSpy = jest.spyOn(API, "getWorkspaceAgentLogs");
const wsSpy = jest.spyOn(APIModule, "watchWorkspaceAgentLogs");
- const { result } = renderUseAgentLogs(queryClient, "ready");
+ const { result } = renderUseAgentLogs(queryClient, {
+ workspaceId: MockWorkspace.id,
+ agentId: MockWorkspaceAgent.id,
+ agentLifeCycleState: "ready",
+ });
await waitFor(() => {
expect(result.current).toHaveLength(5);
});
@@ -42,13 +48,17 @@ describe("useAgentLogs", () => {
expect(wsSpy).not.toHaveBeenCalled();
});
- it("should fetch logs when empty", async () => {
+ it("should fetch logs when empty and should not connect to WebSocket when not starting", async () => {
const queryClient = createTestQueryClient();
const fetchSpy = jest
.spyOn(API, "getWorkspaceAgentLogs")
.mockResolvedValueOnce(generateLogs(5));
const wsSpy = jest.spyOn(APIModule, "watchWorkspaceAgentLogs");
- const { result } = renderUseAgentLogs(queryClient, "ready");
+ const { result } = renderUseAgentLogs(queryClient, {
+ workspaceId: MockWorkspace.id,
+ agentId: MockWorkspaceAgent.id,
+ agentLifeCycleState: "ready",
+ });
await waitFor(() => {
expect(result.current).toHaveLength(5);
});
@@ -68,7 +78,11 @@ describe("useAgentLogs", () => {
MockWorkspaceAgent.id
}/logs?follow&after=${logs[logs.length - 1].id}`,
);
- const { result } = renderUseAgentLogs(queryClient, "starting");
+ const { result } = renderUseAgentLogs(queryClient, {
+ workspaceId: MockWorkspace.id,
+ agentId: MockWorkspaceAgent.id,
+ agentLifeCycleState: "starting",
+ });
await waitFor(() => {
expect(result.current).toHaveLength(5);
});
@@ -89,7 +103,11 @@ describe("useAgentLogs", () => {
MockWorkspaceAgent.id
}/logs?follow&after=${logs[logs.length - 1].id}`,
);
- const { result } = renderUseAgentLogs(queryClient, "starting");
+ const { result } = renderUseAgentLogs(queryClient, {
+ workspaceId: MockWorkspace.id,
+ agentId: MockWorkspaceAgent.id,
+ agentLifeCycleState: "starting",
+ });
await waitFor(() => {
expect(result.current).toHaveLength(5);
});
@@ -105,25 +123,13 @@ describe("useAgentLogs", () => {
function renderUseAgentLogs(
queryClient: QueryClient,
- lifeCycleState: WorkspaceAgentLifecycle,
- options?: UseAgentLogsOptions,
+ options: UseAgentLogsOptions,
) {
- return renderHook(
- () =>
- useAgentLogs(
- MockWorkspace.id,
- MockWorkspaceAgent.id,
- lifeCycleState,
- options,
- ),
- {
- wrapper: ({ children }) => (
-
- {children}
-
- ),
- },
- );
+ return renderHook(() => useAgentLogs(options), {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ });
}
function generateLogs(count: number): WorkspaceAgentLog[] {
diff --git a/site/src/modules/resources/AgentLogs/useAgentLogs.ts b/site/src/modules/resources/AgentLogs/useAgentLogs.ts
index 8cb99ada2f6cc..5972ade7b5cfa 100644
--- a/site/src/modules/resources/AgentLogs/useAgentLogs.ts
+++ b/site/src/modules/resources/AgentLogs/useAgentLogs.ts
@@ -8,51 +8,60 @@ import type {
} from "api/typesGenerated";
import { useEffectEvent } from "hooks/hookPolyfills";
-export type UseAgentLogsOptions = {
+export type UseAgentLogsOptions = Readonly<{
+ workspaceId: string;
+ agentId: string;
+ agentLifeCycleState: WorkspaceAgentLifecycle;
enabled?: boolean;
-};
+}>;
-export const useAgentLogs = (
- workspaceId: string,
- agentId: string,
- agentLifeCycleState: WorkspaceAgentLifecycle,
- options?: UseAgentLogsOptions,
-) => {
+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({
+
+ const { data } = useQuery({
...queryOptions,
- enabled: options?.enabled,
+ enabled,
+ select: (logs) => {
+ return {
+ logs,
+ lastLogId: logs.at(-1)?.id ?? 0,
+ };
+ },
});
- const logs = query.data;
- const lastQueriedLogId = useRef(0);
- useEffect(() => {
- if (logs && lastQueriedLogId.current === 0) {
- lastQueriedLogId.current = logs[logs.length - 1].id;
- }
- }, [logs]);
+ const socketRef = useRef(null);
+ const lastInitializedAgentIdRef = useRef(null);
const addLogs = useEffectEvent((newLogs: WorkspaceAgentLog[]) => {
queryClient.setQueryData(
queryOptions.queryKey,
- (oldData: WorkspaceAgentLog[] = []) => [...oldData, ...newLogs],
+ (oldLogs: WorkspaceAgentLog[] = []) => [...oldLogs, ...newLogs],
);
});
useEffect(() => {
- if (agentLifeCycleState !== "starting" || !query.isFetched) {
+ const isSameAgentId = agentId === lastInitializedAgentIdRef.current;
+ if (!isSameAgentId) {
+ socketRef.current?.close();
+ }
+
+ const cannotCreateSocket =
+ agentLifeCycleState !== "starting" || data === undefined;
+ if (cannotCreateSocket) {
return;
}
const socket = watchWorkspaceAgentLogs(agentId, {
- after: lastQueriedLogId.current,
+ after: data.lastLogId,
onMessage: (newLogs) => {
// Prevent new logs getting added when a connection is not open
- if (socket.readyState !== WebSocket.OPEN) {
- return;
+ if (socket.readyState === WebSocket.OPEN) {
+ addLogs(newLogs);
}
- addLogs(newLogs);
},
onError: (error) => {
// For some reason Firefox and Safari throw an error when a websocket
@@ -65,10 +74,18 @@ export const useAgentLogs = (
},
});
- return () => {
- socket.close();
- };
- }, [addLogs, agentId, agentLifeCycleState, query.isFetched]);
+ socketRef.current = socket;
+ lastInitializedAgentIdRef.current = agentId;
+ }, [addLogs, agentId, agentLifeCycleState, data]);
+
+ // The above effect is likely going to run a lot because we don't know when or
+ // how agentLifeCycleState will change over time (it's a union of nine
+ // values). The only way to ensure that we only close when we unmount is by
+ // putting the logic into a separate effect with an empty dependency array
+ useEffect(() => {
+ const closeSocketOnUnmount = () => socketRef.current?.close();
+ return closeSocketOnUnmount;
+ }, []);
- return logs;
-};
+ return data?.logs;
+}
From 8da2d2883e1f684be8585515e224e51e6b9eb5d0 Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Thu, 6 Jun 2024 14:33:30 +0000
Subject: [PATCH 20/26] Improve naming
---
.../WorkspaceActions/DownloadLogsDialog.tsx | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
index 8d6090469b835..aefd4d7d6b9e1 100644
--- a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx
@@ -21,7 +21,7 @@ type DownloadLogsDialogProps = Pick<
download?: (zip: Blob, filename: string) => void;
};
-type DownloadFile = {
+type DownloadableFile = {
name: string;
blob: Blob | undefined;
};
@@ -43,8 +43,8 @@ export const DownloadLogsDialog: FC = ({
...buildLogs(workspace),
enabled: dialogProps.open,
});
- const downloadFiles: DownloadFile[] = useMemo(() => {
- const files: DownloadFile[] = [
+ const downloadableFiles: DownloadableFile[] = useMemo(() => {
+ const files: DownloadableFile[] = [
{
name: `${workspace.name}-build-logs.txt`,
blob: buildLogsQuery.data
@@ -68,7 +68,7 @@ export const DownloadLogsDialog: FC = ({
return files;
}, [agentLogResults, agents, buildLogsQuery.data, workspace.name]);
- const isLoadingFiles = downloadFiles.some((f) => f.blob === undefined);
+ const isLoadingFiles = downloadableFiles.some((f) => f.blob === undefined);
const [isDownloading, setIsDownloading] = useState(false);
return (
@@ -83,7 +83,7 @@ export const DownloadLogsDialog: FC = ({
try {
setIsDownloading(true);
const zip = new JSZip();
- downloadFiles.forEach((f) => {
+ downloadableFiles.forEach((f) => {
if (f.blob) {
zip.file(f.name, f.blob);
}
@@ -107,7 +107,7 @@ export const DownloadLogsDialog: FC = ({
jobs in this workspace. This may take a while.
- {downloadFiles.map((f) => (
+ {downloadableFiles.map((f) => (
-
{f.name}
From b54c72c998497c9c6e90ee9d1071d7ef8833338d Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Thu, 6 Jun 2024 14:37:42 +0000
Subject: [PATCH 21/26] Improve naming
---
site/src/api/queries/util.ts | 4 ++--
site/src/api/queries/workspaces.ts | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/site/src/api/queries/util.ts b/site/src/api/queries/util.ts
index e34e20e556a01..fe1b55b68e58d 100644
--- a/site/src/api/queries/util.ts
+++ b/site/src/api/queries/util.ts
@@ -1,7 +1,7 @@
import type { UseQueryOptions, QueryKey } from "react-query";
import type { MetadataState, MetadataValue } from "hooks/useEmbeddedMetadata";
-export const disabledFetchOptions = {
+export const disabledRefetchOptions = {
cacheTime: Infinity,
staleTime: Infinity,
refetchOnMount: false,
@@ -62,7 +62,7 @@ export function cachedQuery<
// Make sure the disabled options are always serialized last, so that no
// one using this function can accidentally override the values
- ...(metadata.available ? disabledFetchOptions : {}),
+ ...(metadata.available ? disabledRefetchOptions : {}),
};
return newOptions as FormattedQueryOptionsResult<
diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts
index 33be420d0164d..809c8a4c2862f 100644
--- a/site/src/api/queries/workspaces.ts
+++ b/site/src/api/queries/workspaces.ts
@@ -14,7 +14,7 @@ import type {
WorkspacesRequest,
WorkspacesResponse,
} from "api/typesGenerated";
-import { disabledFetchOptions } from "./util";
+import { disabledRefetchOptions } from "./util";
import { workspaceBuildsKey } from "./workspaceBuilds";
export const workspaceByOwnerAndNameKey = (owner: string, name: string) => [
@@ -310,6 +310,6 @@ export const agentLogs = (workspaceId: string, agentId: string) => {
return {
queryKey: agentLogsKey(workspaceId, agentId),
queryFn: () => API.getWorkspaceAgentLogs(agentId),
- ...disabledFetchOptions,
+ ...disabledRefetchOptions,
};
};
From 83f60dd68a8b495b8490492abef07742aa1bfda3 Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Thu, 6 Jun 2024 14:46:54 +0000
Subject: [PATCH 22/26] Fix hook usage
---
site/src/modules/resources/AgentRow.tsx | 12 ++++++------
.../WorkspaceBuildPage/WorkspaceBuildPageView.tsx | 6 +++++-
2 files changed, 11 insertions(+), 7 deletions(-)
diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx
index 7c588c884a881..7b6395cad3297 100644
--- a/site/src/modules/resources/AgentRow.tsx
+++ b/site/src/modules/resources/AgentRow.tsx
@@ -91,12 +91,12 @@ export const AgentRow: FC = ({
["starting", "start_timeout"].includes(agent.lifecycle_state) &&
hasStartupFeatures,
);
- const agentLogs = useAgentLogs(
- workspace.id,
- agent.id,
- agent.lifecycle_state,
- { enabled: showLogs },
- );
+ const agentLogs = useAgentLogs({
+ workspaceId: workspace.id,
+ agentId: agent.id,
+ agentLifeCycleState: agent.lifecycle_state,
+ enabled: showLogs,
+ });
const logListRef = useRef
(null);
const logListDivRef = useRef(null);
const startupLogs = useMemo(() => {
diff --git a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx
index 7e185889aacc6..4f9a9a4d48890 100644
--- a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx
+++ b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx
@@ -230,7 +230,11 @@ const AgentLogsContent: FC<{ workspaceId: string; agent: WorkspaceAgent }> = ({
agent,
workspaceId,
}) => {
- const logs = useAgentLogs(workspaceId, agent.id, agent.lifecycle_state);
+ const logs = useAgentLogs({
+ workspaceId,
+ agentId: agent.id,
+ agentLifeCycleState: agent.lifecycle_state,
+ });
if (!logs) {
return ;
From 6f854909502da9e9666de3b80a4a1b0baa1ec9e2 Mon Sep 17 00:00:00 2001
From: Jon Ayers
Date: Thu, 6 Jun 2024 19:07:46 +0000
Subject: [PATCH 23/26] allow pkg
---
.github/workflows/ci.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index e092cef28ab02..7d1e9837c8682 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -922,7 +922,7 @@ jobs:
uses: actions/dependency-review-action@v4.3.2
with:
allow-licenses: Apache-2.0, BSD-2-Clause, BSD-3-Clause, CC0-1.0, ISC, MIT, MIT-0, MPL-2.0
- allow-dependencies-licenses: "pkg:golang/github.com/coder/wgtunnel@0.1.13-0.20240522110300-ade90dfb2da0"
+ allow-dependencies-licenses: "pkg:golang/github.com/coder/wgtunnel@0.1.13-0.20240522110300-ade90dfb2da0, pkg:npm/pako@1.0.11"
license-check: true
vulnerability-check: false
- name: "Report"
From 270e04ea20e4636e0dbb91b551699b6829637a1c Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Thu, 6 Jun 2024 19:23:54 +0000
Subject: [PATCH 24/26] Rollback useAgentLogs implementation
---
.../resources/AgentLogs/useAgentLogs.ts | 54 +++++++------------
1 file changed, 19 insertions(+), 35 deletions(-)
diff --git a/site/src/modules/resources/AgentLogs/useAgentLogs.ts b/site/src/modules/resources/AgentLogs/useAgentLogs.ts
index 5972ade7b5cfa..e5d797a14e9c2 100644
--- a/site/src/modules/resources/AgentLogs/useAgentLogs.ts
+++ b/site/src/modules/resources/AgentLogs/useAgentLogs.ts
@@ -21,47 +21,39 @@ export function useAgentLogs(
const { workspaceId, agentId, agentLifeCycleState, enabled = true } = options;
const queryClient = useQueryClient();
const queryOptions = agentLogs(workspaceId, agentId);
-
- const { data } = useQuery({
+ const query = useQuery({
...queryOptions,
enabled,
- select: (logs) => {
- return {
- logs,
- lastLogId: logs.at(-1)?.id ?? 0,
- };
- },
});
+ const logs = query.data;
- const socketRef = useRef(null);
- const lastInitializedAgentIdRef = useRef(null);
+ const lastQueriedLogId = useRef(0);
+ useEffect(() => {
+ if (logs && lastQueriedLogId.current === 0) {
+ lastQueriedLogId.current = logs[logs.length - 1].id;
+ }
+ }, [logs]);
const addLogs = useEffectEvent((newLogs: WorkspaceAgentLog[]) => {
queryClient.setQueryData(
queryOptions.queryKey,
- (oldLogs: WorkspaceAgentLog[] = []) => [...oldLogs, ...newLogs],
+ (oldData: WorkspaceAgentLog[] = []) => [...oldData, ...newLogs],
);
});
useEffect(() => {
- const isSameAgentId = agentId === lastInitializedAgentIdRef.current;
- if (!isSameAgentId) {
- socketRef.current?.close();
- }
-
- const cannotCreateSocket =
- agentLifeCycleState !== "starting" || data === undefined;
- if (cannotCreateSocket) {
+ if (agentLifeCycleState !== "starting" || !query.isFetched) {
return;
}
const socket = watchWorkspaceAgentLogs(agentId, {
- after: data.lastLogId,
+ after: lastQueriedLogId.current,
onMessage: (newLogs) => {
// Prevent new logs getting added when a connection is not open
- if (socket.readyState === WebSocket.OPEN) {
- addLogs(newLogs);
+ if (socket.readyState !== WebSocket.OPEN) {
+ return;
}
+ addLogs(newLogs);
},
onError: (error) => {
// For some reason Firefox and Safari throw an error when a websocket
@@ -74,18 +66,10 @@ export function useAgentLogs(
},
});
- socketRef.current = socket;
- lastInitializedAgentIdRef.current = agentId;
- }, [addLogs, agentId, agentLifeCycleState, data]);
-
- // The above effect is likely going to run a lot because we don't know when or
- // how agentLifeCycleState will change over time (it's a union of nine
- // values). The only way to ensure that we only close when we unmount is by
- // putting the logic into a separate effect with an empty dependency array
- useEffect(() => {
- const closeSocketOnUnmount = () => socketRef.current?.close();
- return closeSocketOnUnmount;
- }, []);
+ return () => {
+ socket.close();
+ };
+ }, [addLogs, agentId, agentLifeCycleState, query.isFetched]);
- return data?.logs;
+ return logs;
}
From a6ab9fdfbca45c08bc1ba99fa925e50047fa08a5 Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Thu, 6 Jun 2024 19:26:28 +0000
Subject: [PATCH 25/26] Add decorators to fix storybook
---
.../WorkspaceActions/DownloadLogsDialog.stories.tsx | 7 +++++++
.../WorkspaceActions/WorkspaceActions.stories.tsx | 7 +++++++
2 files changed, 14 insertions(+)
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx
index de6c026c66c21..ddeaf6fb46634 100644
--- a/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx
@@ -24,6 +24,13 @@ const meta: Meta = {
},
],
},
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
};
export default meta;
diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx
index b87a8ba8764c1..3e663dafba1a9 100644
--- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx
+++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx
@@ -10,6 +10,13 @@ const meta: Meta = {
args: {
isUpdating: false,
},
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
};
export default meta;
From fab7d56a16014c97d79dce6b80a20014aaef4dca Mon Sep 17 00:00:00 2001
From: BrunoQuaresma
Date: Fri, 7 Jun 2024 12:46:42 +0000
Subject: [PATCH 26/26] Apply Asher suggestions
---
site/src/modules/resources/DownloadAgentLogsButton.tsx | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/site/src/modules/resources/DownloadAgentLogsButton.tsx b/site/src/modules/resources/DownloadAgentLogsButton.tsx
index a6e2d6b670365..d127069d895b2 100644
--- a/site/src/modules/resources/DownloadAgentLogsButton.tsx
+++ b/site/src/modules/resources/DownloadAgentLogsButton.tsx
@@ -44,16 +44,15 @@ export const DownloadAgentLogsButton: FC = ({
setIsDownloading(true);
const logs = await fetchLogs();
if (!logs) {
- displayError("Failed to fetch logs");
- setIsDownloading(false);
- return;
+ throw new Error("No logs found");
}
const text = logs.map((l) => l.output).join("\n");
const file = new Blob([text], { type: "text/plain" });
download(file, `${agent.name}-logs.txt`);
- setIsDownloading(false);
} catch (e) {
+ console.error(e);
displayError("Failed to download logs");
+ } finally {
setIsDownloading(false);
}
}}