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" 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==} 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/util.ts b/site/src/api/queries/util.ts index 6043b984fab93..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"; -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 95df3b7f592f6..809c8a4c2862f 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 { disabledRefetchOptions } from "./util"; import { workspaceBuildsKey } from "./workspaceBuilds"; export const workspaceByOwnerAndNameKey = (owner: string, name: string) => [ @@ -283,3 +284,32 @@ 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), + }; +}; + +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), + ...disabledRefetchOptions, + }; +}; 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, diff --git a/site/src/modules/resources/AgentLogs/AgentLogs.tsx b/site/src/modules/resources/AgentLogs/AgentLogs.tsx index 407e3c12fe9b5..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,64 +172,6 @@ export const AgentLogs = forwardRef( }, ); -export const useAgentLogs = ( - agentId: string, - options?: { enabled?: boolean; initialData?: LineWithID[] }, -) => { - const initialData = options?.initialData; - const enabled = options?.enabled === undefined ? true : options.enabled; - const [logs, setLogs] = useState(initialData); - - 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.test.tsx b/site/src/modules/resources/AgentLogs/useAgentLogs.test.tsx new file mode 100644 index 0000000000000..5323a8bf57f26 --- /dev/null +++ b/site/src/modules/resources/AgentLogs/useAgentLogs.test.tsx @@ -0,0 +1,143 @@ +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 { 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, { + workspaceId: MockWorkspace.id, + agentId: MockWorkspaceAgent.id, + agentLifeCycleState: "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, { + workspaceId: MockWorkspace.id, + agentId: MockWorkspaceAgent.id, + agentLifeCycleState: "ready", + }); + await waitFor(() => { + expect(result.current).toHaveLength(5); + }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(wsSpy).not.toHaveBeenCalled(); + }); + + 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, { + workspaceId: MockWorkspace.id, + agentId: MockWorkspaceAgent.id, + agentLifeCycleState: "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, { + workspaceId: MockWorkspace.id, + agentId: MockWorkspaceAgent.id, + agentLifeCycleState: "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, { + workspaceId: MockWorkspace.id, + agentId: MockWorkspaceAgent.id, + agentLifeCycleState: "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, + options: UseAgentLogsOptions, +) { + return renderHook(() => useAgentLogs(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 new file mode 100644 index 0000000000000..e5d797a14e9c2 --- /dev/null +++ b/site/src/modules/resources/AgentLogs/useAgentLogs.ts @@ -0,0 +1,75 @@ +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 type UseAgentLogsOptions = Readonly<{ + workspaceId: string; + agentId: string; + agentLifeCycleState: WorkspaceAgentLifecycle; + enabled?: boolean; +}>; + +export function useAgentLogs( + options: UseAgentLogsOptions, +): readonly WorkspaceAgentLog[] | undefined { + const { workspaceId, agentId, agentLifeCycleState, enabled = true } = options; + const queryClient = useQueryClient(); + const queryOptions = agentLogs(workspaceId, agentId); + const query = useQuery({ + ...queryOptions, + enabled, + }); + const logs = query.data; + + const 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.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.test.tsx b/site/src/modules/resources/AgentRow.test.tsx index 4ae3f1536b659..0ad222fc09740 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..7b6395cad3297 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, @@ -24,15 +26,14 @@ 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 { AgentLogs, useAgentLogs } from "./AgentLogs/AgentLogs"; +import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine"; +import { AgentLogs } from "./AgentLogs/AgentLogs"; +import { useAgentLogs } from "./AgentLogs/useAgentLogs"; 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"; @@ -51,7 +52,6 @@ export interface AgentRowProps { serverAPIVersion: string; onUpdateAgent: () => void; template: Template; - storybookLogs?: LineWithID[]; storybookAgentMetadata?: WorkspaceAgentMetadata[]; } @@ -68,7 +68,6 @@ export const AgentRow: FC = ({ onUpdateAgent, storybookAgentMetadata, sshPrefix, - storybookLogs, }) => { // XRay integration const xrayScanQuery = useQuery( @@ -92,9 +91,11 @@ export const AgentRow: FC = ({ ["starting", "start_timeout"].includes(agent.lifecycle_state) && hasStartupFeatures, ); - const agentLogs = useAgentLogs(agent.id, { + const agentLogs = useAgentLogs({ + workspaceId: workspace.id, + agentId: agent.id, + agentLifeCycleState: agent.lifecycle_state, enabled: showLogs, - initialData: process.env.STORYBOOK ? storybookLogs || [] : undefined, }); const logListRef = useRef(null); const logListDivRef = useRef(null); @@ -107,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; @@ -289,20 +290,31 @@ 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} /> )} - + + + + + )} @@ -475,32 +487,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.stories.tsx b/site/src/modules/resources/DownloadAgentLogsButton.stories.tsx new file mode 100644 index 0000000000000..712a950decf1a --- /dev/null +++ b/site/src/modules/resources/DownloadAgentLogsButton.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { waitFor, within, userEvent, expect, fn } from "@storybook/test"; +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, + }, + parameters: { + queries: [ + { + key: agentLogsKey(MockWorkspace.id, MockWorkspaceAgent.id), + data: generateLogs(5), + }, + ], + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const ClickOnDownload: Story = { + args: { + download: fn(), + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { name: "Download logs" }), + ); + await waitFor(() => + expect(args.download).toHaveBeenCalledWith( + expect.anything(), + `${MockWorkspaceAgent.name}-logs.txt`, + ), + ); + const blob: Blob = (args.download as jest.Mock).mock.calls[0][0]; + await expect(blob.type).toEqual("text/plain"); + }, +}; + +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 new file mode 100644 index 0000000000000..d127069d895b2 --- /dev/null +++ b/site/src/modules/resources/DownloadAgentLogsButton.tsx @@ -0,0 +1,63 @@ +import DownloadOutlined from "@mui/icons-material/DownloadOutlined"; +import Button from "@mui/material/Button"; +import { saveAs } from "file-saver"; +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 = { + workspaceId: string; + agent: Pick; + download?: (file: Blob, filename: string) => void; +}; + +export const DownloadAgentLogsButton: FC = ({ + workspaceId, + agent, + download = saveAs, +}) => { + 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 ( + + ); +}; diff --git a/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx b/site/src/pages/WorkspaceBuildPage/WorkspaceBuildPageView.tsx index ed62c37ee29f6..4f9a9a4d48890 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,15 @@ 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, + agentId: agent.id, + agentLifeCycleState: agent.lifecycle_state, + }); if (!logs) { return ; @@ -232,7 +243,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%" /> 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..ddeaf6fb46634 --- /dev/null +++ b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.stories.tsx @@ -0,0 +1,78 @@ +import type { Meta, StoryObj } from "@storybook/react"; +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"; + +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), + }, + ], + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +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 userEvent.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 new file mode 100644 index 0000000000000..aefd4d7d6b9e1 --- /dev/null +++ b/site/src/pages/WorkspacePage/WorkspaceActions/DownloadLogsDialog.tsx @@ -0,0 +1,166 @@ +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 { agentLogs, buildLogs } from "api/queries/workspaces"; +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; + download?: (zip: Blob, filename: string) => void; +}; + +type DownloadableFile = { + name: string; + blob: Blob | undefined; +}; + +export const DownloadLogsDialog: FC = ({ + workspace, + download = saveAs, + ...dialogProps +}) => { + const theme = useTheme(); + const agents = selectAgents(workspace); + const agentLogResults = useQueries({ + queries: agents.map((a) => ({ + ...agentLogs(workspace.id, a.id), + enabled: dialogProps.open, + })), + }); + const buildLogsQuery = useQuery({ + ...buildLogs(workspace), + enabled: dialogProps.open, + }); + const downloadableFiles: DownloadableFile[] = useMemo(() => { + const files: DownloadableFile[] = [ + { + name: `${workspace.name}-build-logs.txt`, + blob: buildLogsQuery.data + ? new Blob([buildLogsQuery.data.map((l) => l.output).join("\n")], { + type: "text/plain", + }) + : undefined, + }, + ]; + + agents.forEach((a, i) => { + const 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, buildLogsQuery.data, workspace.name]); + const isLoadingFiles = downloadableFiles.some((f) => f.blob === undefined); + const [isDownloading, setIsDownloading] = useState(false); + + return ( + { + try { + setIsDownloading(true); + const zip = new JSZip(); + downloadableFiles.forEach((f) => { + if (f.blob) { + zip.file(f.name, f.blob); + } + }); + const content = await zip.generateAsync({ type: "blob" }); + download(content, `${workspace.name}-logs.zip`); + dialogProps.onClose(); + setTimeout(() => { + 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. +

+
    + {downloadableFiles.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", + alignItems: "center", + }, + listItemPrimary: (theme) => ({ + fontWeight: 500, + color: theme.palette.text.primary, + }), + listItemSecondary: { + fontSize: 14, + }, +} satisfies Record>; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx index ce03863b69c55..3e663dafba1a9 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 { userEvent, within, expect } from "@storybook/test"; +import { buildLogsKey, agentLogsKey } from "api/queries/workspaces"; import * as Mocks from "testHelpers/entities"; import { WorkspaceActions } from "./WorkspaceActions"; @@ -8,6 +10,13 @@ const meta: Meta = { args: { isUpdating: false, }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], }; export default meta; @@ -140,3 +149,34 @@ 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 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(); + }, +}; + +function generateLogs(count: number) { + return Array.from({ length: count }, (_, i) => ({ + output: `log ${i + 1}`, + })); +} 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={() => {}} + /> ); };