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 = ({ - + + + + + )} @@ -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 ( + + ); +}; 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 ( ); }; 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); } }}