mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): add download logs option (#13466)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+44
-6
@@ -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==}
|
||||
|
||||
+1
-2
@@ -1142,10 +1142,9 @@ class ApiMethods {
|
||||
|
||||
getWorkspaceBuildLogs = async (
|
||||
buildId: string,
|
||||
before: Date,
|
||||
): Promise<TypesGen.ProvisionerJobLog[]> => {
|
||||
const response = await this.axios.get<TypesGen.ProvisionerJobLog[]>(
|
||||
`/api/v2/workspacebuilds/${buildId}/logs?before=${before.getTime()}`,
|
||||
`/api/v2/workspacebuilds/${buildId}/logs`,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,11 +26,11 @@ export const DropdownArrow: FC<ArrowProps> = ({
|
||||
};
|
||||
|
||||
const styles = {
|
||||
base: (theme) => ({
|
||||
color: theme.palette.primary.contrastText,
|
||||
base: {
|
||||
color: "currentcolor",
|
||||
width: 16,
|
||||
height: 16,
|
||||
}),
|
||||
},
|
||||
|
||||
withMargin: {
|
||||
marginLeft: 8,
|
||||
|
||||
@@ -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<List, AgentLogsProps>(
|
||||
},
|
||||
);
|
||||
|
||||
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<LineWithID[] | undefined>(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.
|
||||
|
||||
@@ -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 }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
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: "",
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<typeof AgentRow> = {
|
||||
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<typeof AgentRow> = {
|
||||
</ProxyContext.Provider>
|
||||
),
|
||||
withDashboardProvider,
|
||||
withWebSocket,
|
||||
],
|
||||
parameters: {
|
||||
chromatic,
|
||||
queries: [
|
||||
{
|
||||
key: ["portForward", M.MockWorkspaceAgent.id],
|
||||
data: M.MockListeningPortsResponse,
|
||||
},
|
||||
],
|
||||
webSocket: [
|
||||
{
|
||||
event: "message",
|
||||
data: JSON.stringify(logs),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<AgentRowProps> = ({
|
||||
onUpdateAgent,
|
||||
storybookAgentMetadata,
|
||||
sshPrefix,
|
||||
storybookLogs,
|
||||
}) => {
|
||||
// XRay integration
|
||||
const xrayScanQuery = useQuery(
|
||||
@@ -92,9 +91,11 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
["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<List>(null);
|
||||
const logListDivRef = useRef<HTMLDivElement>(null);
|
||||
@@ -107,8 +108,8 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
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<AgentRowProps> = ({
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</Collapse>
|
||||
|
||||
<button
|
||||
css={styles.logsPanelButton}
|
||||
onClick={() => setShowLogs((v) => !v)}
|
||||
>
|
||||
<DropdownArrow close={showLogs} margin={false} />
|
||||
{showLogs ? "Hide" : "Show"} logs
|
||||
</button>
|
||||
<Stack css={{ padding: "12px 16px" }} direction="row" spacing={1}>
|
||||
<Button
|
||||
variant="text"
|
||||
size="small"
|
||||
startIcon={<DropdownArrow close={showLogs} margin={false} />}
|
||||
onClick={() => setShowLogs((v) => !v)}
|
||||
>
|
||||
Logs
|
||||
</Button>
|
||||
<Divider orientation="vertical" variant="middle" flexItem />
|
||||
<DownloadAgentLogsButton workspaceId={workspace.id} agent={agent} />
|
||||
</Stack>
|
||||
</section>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<typeof DownloadAgentLogsButton> = {
|
||||
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<typeof DownloadAgentLogsButton>;
|
||||
|
||||
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: "",
|
||||
}));
|
||||
}
|
||||
@@ -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<WorkspaceAgent, "id" | "name" | "status" | "lifecycle_state">;
|
||||
download?: (file: Blob, filename: string) => void;
|
||||
};
|
||||
|
||||
export const DownloadAgentLogsButton: FC<DownloadAgentLogsButtonProps> = ({
|
||||
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<WorkspaceAgentLog[]>(
|
||||
queryOpts.queryKey,
|
||||
);
|
||||
if (!logs) {
|
||||
logs = await queryClient.fetchQuery(queryOpts);
|
||||
}
|
||||
return logs;
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
startIcon={<DownloadOutlined />}
|
||||
disabled={!isConnected || isDownloading}
|
||||
variant="text"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
const logs = await fetchLogs();
|
||||
if (!logs) {
|
||||
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`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
displayError("Failed to download logs");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isDownloading ? "Downloading..." : "Download logs"}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -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<WorkspaceBuildPageViewProps> = ({
|
||||
{tabState.value === "build" ? (
|
||||
<BuildLogsContent logs={logs} />
|
||||
) : (
|
||||
<AgentLogsContent agent={selectedAgent!} />
|
||||
<AgentLogsContent
|
||||
workspaceId={build.workspace_id}
|
||||
agent={selectedAgent!}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 <Loader />;
|
||||
@@ -232,7 +243,13 @@ const AgentLogsContent: FC<{ agent: WorkspaceAgent }> = ({ agent }) => {
|
||||
return (
|
||||
<AgentLogs
|
||||
sources={agent.log_sources}
|
||||
logs={logs}
|
||||
logs={logs.map((l) => ({
|
||||
id: l.id,
|
||||
output: l.output,
|
||||
time: l.created_at,
|
||||
level: l.level,
|
||||
sourceId: l.source_id,
|
||||
}))}
|
||||
height={560}
|
||||
width="100%"
|
||||
/>
|
||||
|
||||
@@ -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<typeof DownloadLogsDialog> = {
|
||||
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) => (
|
||||
<div css={{ width: 1200, height: 800 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DownloadLogsDialog>;
|
||||
|
||||
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}`,
|
||||
}));
|
||||
}
|
||||
@@ -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<DownloadLogsDialogProps> = ({
|
||||
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 (
|
||||
<ConfirmDialog
|
||||
{...dialogProps}
|
||||
hideCancel={false}
|
||||
title="Download logs"
|
||||
confirmText="Download"
|
||||
disabled={isLoadingFiles}
|
||||
confirmLoading={isDownloading}
|
||||
onConfirm={async () => {
|
||||
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={
|
||||
<Stack css={{ paddingBottom: 16 }}>
|
||||
<p>
|
||||
Downloading logs will create a zip file containing all logs from all
|
||||
jobs in this workspace. This may take a while.
|
||||
</p>
|
||||
<ul css={styles.list}>
|
||||
{downloadableFiles.map((f) => (
|
||||
<li key={f.name} css={styles.listItem}>
|
||||
<span css={styles.listItemPrimary}>{f.name}</span>
|
||||
<span css={styles.listItemSecondary}>
|
||||
{f.blob ? (
|
||||
humanBlobSize(f.blob.size)
|
||||
) : (
|
||||
<Skeleton variant="text" width={48} height={12} />
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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<string, Interpolation<Theme>>;
|
||||
@@ -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<typeof WorkspaceActions> = {
|
||||
args: {
|
||||
isUpdating: false,
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div css={{ width: 1200, height: 800 }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
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}`,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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<WorkspaceActionsProps> = ({
|
||||
const { duplicateWorkspace, isDuplicationReady } =
|
||||
useWorkspaceDuplication(workspace);
|
||||
|
||||
const [isDownloadDialogOpen, setIsDownloadDialogOpen] = useState(false);
|
||||
|
||||
const { actions, canCancel, canAcceptJobs } = abilitiesByWorkspaceStatus(
|
||||
workspace,
|
||||
canDebug,
|
||||
@@ -219,6 +223,11 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
|
||||
Duplicate…
|
||||
</MoreMenuItem>
|
||||
|
||||
<MoreMenuItem onClick={() => setIsDownloadDialogOpen(true)}>
|
||||
<DownloadOutlined />
|
||||
Download logs…
|
||||
</MoreMenuItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<MoreMenuItem
|
||||
@@ -231,6 +240,13 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
|
||||
</MoreMenuItem>
|
||||
</MoreMenuContent>
|
||||
</MoreMenu>
|
||||
|
||||
<DownloadLogsDialog
|
||||
workspace={workspace}
|
||||
open={isDownloadDialogOpen}
|
||||
onClose={() => setIsDownloadDialogOpen(false)}
|
||||
onConfirm={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user