feat(site): add download logs option (#13466)

This commit is contained in:
Bruno Quaresma
2024-06-07 10:03:05 -03:00
committed by GitHub
parent 48ecee1025
commit 056a697eff
20 changed files with 801 additions and 149 deletions
+1 -1
View File
@@ -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"
+3
View File
@@ -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",
+44 -6
View File
@@ -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
View File
@@ -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;
+2 -2
View File
@@ -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<
+30
View File
@@ -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;
}
+21 -17
View File
@@ -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;
+2 -1
View File
@@ -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", () => {
+31 -45
View File
@@ -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&hellip;
</MoreMenuItem>
<MoreMenuItem onClick={() => setIsDownloadDialogOpen(true)}>
<DownloadOutlined />
Download logs&hellip;
</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>
);
};