fix(site): update useAgentLogs to make it more testable and add more tests (#19126)

Take 2
Closes https://github.com/coder/internal/issues/644

## Changes made
- Updated how `useAgentLogs` was defined to make it easier to inject
specific data dependencies (basically making the hook more
unit-testable)
- Simplified the hook API to limit the amount of scope of data it needs
to work
- Added more test cases, and re-enabled the one test case we had
previously disabled
- Extracted our mock websocket code into a separate file, and added more
methods to it
- Updated all runtime code to accommodate new changes
This commit is contained in:
Michael Smith
2025-09-17 19:58:02 -04:00
committed by GitHub
parent d3bf5065a0
commit 759746cbf1
18 changed files with 479 additions and 284 deletions
+1
View File
@@ -54,6 +54,7 @@
}
},
"tailwindCSS.classFunctions": ["cva", "cn"],
"[css][html][markdown][yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
+4 -2
View File
@@ -4,9 +4,11 @@ import type { FC } from "react";
import { Link } from "../Link/Link";
import { Alert, AlertDetail, type AlertProps } from "./Alert";
export const ErrorAlert: FC<
type ErrorAlertProps = Readonly<
Omit<AlertProps, "severity" | "children"> & { error: unknown }
> = ({ error, ...alertProps }) => {
>;
export const ErrorAlert: FC<ErrorAlertProps> = ({ error, ...alertProps }) => {
const message = getErrorMessage(error, "Something went wrong.");
const detail = getErrorDetail(error);
const status = getErrorStatus(error);
@@ -21,6 +21,24 @@ export const Warning: Story = {
},
};
export const Destructive: Story = {
args: {
variant: "destructive",
},
};
export const Info: Story = {
args: {
variant: "info",
},
};
export const Green: Story = {
args: {
variant: "green",
},
};
export const SmallWithIcon: Story = {
args: {
variant: "default",
+3 -3
View File
@@ -23,8 +23,8 @@ const badgeVariants = cva(
destructive:
"border border-solid border-border-destructive bg-surface-red text-highlight-red shadow",
green:
"border border-solid border-surface-green bg-surface-green text-highlight-green shadow",
info: "border border-solid border-surface-sky bg-surface-sky text-highlight-sky shadow",
"border border-solid border-border-green bg-surface-green text-highlight-green shadow",
info: "border border-solid border-border-sky bg-surface-sky text-highlight-sky shadow",
},
size: {
xs: "text-2xs font-regular h-5 [&_svg]:hidden rounded px-1.5",
@@ -50,7 +50,7 @@ const badgeVariants = cva(
defaultVariants: {
variant: "default",
size: "md",
border: "solid",
border: "none",
hover: false,
},
},
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { screen, userEvent, within } from "storybook/test";
import { Latency } from "./Latency";
const meta: Meta<typeof Latency> = {
@@ -32,3 +33,19 @@ export const Loading: Story = {
isLoading: true,
},
};
export const NoLatency: Story = {
args: {
latency: undefined,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const tooltipTrigger = canvas.getByLabelText(/Latency not available/i);
await userEvent.hover(tooltipTrigger);
// Need to await getting the tooltip because the tooltip doesn't open
// immediately on hover
await screen.findByRole("tooltip", { name: /Latency not available/i });
},
};
+17 -14
View File
@@ -1,22 +1,24 @@
import { useTheme } from "@emotion/react";
import CircularProgress from "@mui/material/CircularProgress";
import Tooltip from "@mui/material/Tooltip";
import { visuallyHidden } from "@mui/utils";
import { Abbr } from "components/Abbr/Abbr";
import { CircleHelpIcon } from "lucide-react";
import type { FC } from "react";
import { cn } from "utils/cn";
import { getLatencyColor } from "utils/latency";
interface LatencyProps {
latency?: number;
isLoading?: boolean;
size?: number;
className?: string;
iconClassName?: string;
}
export const Latency: FC<LatencyProps> = ({
latency,
isLoading,
size = 14,
className,
iconClassName,
}) => {
const theme = useTheme();
// Always use the no latency color for loading.
@@ -24,28 +26,29 @@ export const Latency: FC<LatencyProps> = ({
if (isLoading) {
return (
<Tooltip title="Loading latency...">
<CircularProgress size={size} className="ml-auto" style={{ color }} />
<Tooltip title="Loading latency..." className={className}>
<CircularProgress
className={cn("!size-icon-xs", iconClassName)}
style={{ color }}
/>
</Tooltip>
);
}
if (!latency) {
const notAvailableText = "Latency not available";
return (
<Tooltip title={notAvailableText}>
<>
<span css={{ ...visuallyHidden }}>{notAvailableText}</span>
<CircleHelpIcon className="ml-auto size-icon-sm" style={{ color }} />
</>
<Tooltip title="Latency not available" className={className}>
<CircleHelpIcon
className={cn("!size-icon-sm", iconClassName)}
style={{ color }}
/>
</Tooltip>
);
}
return (
<div className="ml-auto text-sm" style={{ color }}>
<span css={{ ...visuallyHidden }}>Latency: </span>
<div className={cn("text-sm", className)} style={{ color }}>
<span className="sr-only">Latency: </span>
{latency.toFixed(0)}
<Abbr title="milliseconds">ms</Abbr>
</div>
+1 -2
View File
@@ -229,13 +229,12 @@ export function makeUseEmbeddedMetadata(
manager.getMetadata,
);
// biome-ignore lint/correctness/useExhaustiveDependencies(manager.clearMetadataByKey): baked into containing hook
const stableMetadataResult = useMemo<UseEmbeddedMetadataResult>(() => {
return {
metadata,
clearMetadataByKey: manager.clearMetadataByKey,
};
}, [metadata]);
}, [manager, metadata]);
return stableMetadataResult;
};
+4
View File
@@ -32,6 +32,8 @@
--surface-purple: 251 91% 95%;
--border-default: 240 6% 90%;
--border-success: 142 76% 36%;
--border-sky: 203 90% 40%;
--border-green: 138 82% 82%;
--border-warning: 30.66, 97.16%, 72.35%;
--border-destructive: 0 84% 60%;
--border-hover: 240 5% 34%;
@@ -75,6 +77,8 @@
--border-success: 142 76% 36%;
--border-warning: 30.66, 97.16%, 72.35%;
--border-destructive: 0 91% 71%;
--border-sky: 194 90% 62%;
--border-green: 143 77% 87%;
--border-hover: 240, 5%, 34%;
--overlay-default: 240 10% 4% / 80%;
--highlight-purple: 252 95% 85%;
@@ -162,7 +162,7 @@ const ProxySettingsSub: FC<ProxySettingsSubProps> = ({ proxyContextValue }) => {
<img className="w-4 h-4" src={p.icon_url} alt={p.name} />
{p.display_name || p.name}
{latency ? (
<Latency latency={latency.latencyMS} />
<Latency className="ml-auto" latency={latency.latencyMS} />
) : (
<CircleHelpIcon className="ml-auto" />
)}
@@ -92,7 +92,6 @@ export const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
<Latency
latency={latencies?.[selectedProxy.id]?.latencyMS}
isLoading={proxyLatencyLoading(selectedProxy)}
size={24}
/>
</>
) : (
@@ -191,6 +190,7 @@ export const ProxyMenu: FC<ProxyMenuProps> = ({ proxyContextValue }) => {
{proxy.display_name}
<Latency
className="ml-auto"
latency={latencies?.[proxy.id]?.latencyMS}
isLoading={proxyLatencyLoading(proxy)}
/>
@@ -10,6 +10,7 @@ const meta: Meta<typeof AgentLogs> = {
sources: MockSources,
logs: MockLogs,
height: MockLogs.length * AGENT_LOG_LINE_HEIGHT,
overflowed: false,
},
parameters: {
layout: "fullscreen",
@@ -19,6 +20,11 @@ const meta: Meta<typeof AgentLogs> = {
export default meta;
type Story = StoryObj<typeof AgentLogs>;
const Default: Story = {};
export const Default: Story = {};
export { Default as AgentLogs };
export const Overflowed: Story = {
args: {
className: "max-h-[420px]",
overflowed: true,
},
};
+163 -164
View File
@@ -1,178 +1,189 @@
import type { Interpolation, Theme } from "@emotion/react";
import Tooltip from "@mui/material/Tooltip";
import MuiTooltip from "@mui/material/Tooltip";
import type { WorkspaceAgentLogSource } from "api/typesGenerated";
import { Badge } from "components/Badge/Badge";
import type { Line } from "components/Logs/LogLine";
import { type ComponentProps, forwardRef, type JSX, useMemo } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { type ComponentProps, forwardRef, type JSX } from "react";
import { FixedSizeList as List } from "react-window";
import { cn } from "utils/cn";
import { AGENT_LOG_LINE_HEIGHT, AgentLogLine } from "./AgentLogLine";
// Fallback log used in places where we must always have a valid log source.
// We need this to support deployments that were made before `coder_script` was
// created and that haven't restarted their agents yet
const fallbackLog: WorkspaceAgentLogSource = {
created_at: "",
display_name: "Logs",
icon: "",
id: "00000000-0000-0000-0000-000000000000",
workspace_agent_id: "",
};
type AgentLogsProps = Omit<
ComponentProps<typeof List>,
"children" | "itemSize" | "itemCount"
"children" | "itemSize" | "itemCount" | "itemKey"
> & {
logs: readonly Line[];
sources: readonly WorkspaceAgentLogSource[];
overflowed: boolean;
};
export const AgentLogs = forwardRef<List, AgentLogsProps>(
({ logs, sources, ...listProps }, ref) => {
const logSourceByID = useMemo(() => {
const sourcesById: { [id: string]: WorkspaceAgentLogSource } = {};
for (const source of sources) {
sourcesById[source.id] = source;
}
return sourcesById;
}, [sources]);
({ logs, sources, overflowed, className, ...listProps }, ref) => {
const logSourceById = Object.fromEntries(sources.map((s) => [s.id, s]));
const getLogSource = (id: string) => logSourceById[id] || fallbackLog;
return (
<List
ref={ref}
css={styles.logs}
itemCount={logs.length}
itemSize={AGENT_LOG_LINE_HEIGHT}
{...listProps}
>
{({ index, style }) => {
const log = logs[index];
// getLogSource always returns a valid log source.
// This is necessary to support deployments before `coder_script`.
// Existed that haven't restarted their agents.
const getLogSource = (id: string): WorkspaceAgentLogSource => {
return (
logSourceByID[id] || {
created_at: "",
display_name: "Logs",
icon: "",
id: "00000000-0000-0000-0000-000000000000",
workspace_agent_id: "",
}
);
};
const logSource = getLogSource(log.sourceId);
<div className="bg-surface-secondary relative">
<List
{...listProps}
ref={ref}
itemCount={logs.length}
itemSize={AGENT_LOG_LINE_HEIGHT}
itemKey={(index) => logs[index]?.id || index}
// We need the div selector to be able to apply the padding
// top from startupLogs
className={cn(
"pt-4 [&>div]:relative bg-surface-secondary",
// Add extra padding so that overflow indicator can't
// fully cover up lines of text
overflowed && "pb-10",
className,
)}
>
{({ index, style }) => {
const log = logs[index];
const logSource = getLogSource(log.sourceId);
let assignedIcon = false;
let icon: JSX.Element;
// If no icon is specified, we show a deterministic
// colored circle to identify unique scripts.
if (logSource.icon) {
icon = (
<img
src={logSource.icon}
alt=""
width={14}
height={14}
css={{
marginRight: 8,
flexShrink: 0,
}}
/>
);
} else {
icon = (
<div
css={{
width: 14,
height: 14,
marginRight: 8,
flexShrink: 0,
background: determineScriptDisplayColor(
logSource.display_name,
),
borderRadius: "100%",
}}
/>
);
assignedIcon = true;
}
let nextChangesSource = false;
if (index < logs.length - 1) {
nextChangesSource =
getLogSource(logs[index + 1].sourceId).id !== log.sourceId;
}
// We don't want every line to repeat the icon, because
// that is ugly and repetitive. This removes the icon
// for subsequent lines of the same source and shows a
// line instead, visually indicating they are from the
// same source.
if (
index > 0 &&
getLogSource(logs[index - 1].sourceId).id === log.sourceId
) {
icon = (
<div
css={{
width: 14,
height: 14,
marginRight: 8,
display: "flex",
justifyContent: "center",
position: "relative",
flexShrink: 0,
}}
>
<div
className="dashed-line"
css={(theme) => ({
height: nextChangesSource ? "50%" : "100%",
width: 2,
background: theme.experimental.l1.outline,
borderRadius: 2,
})}
let assignedIcon = false;
let icon: JSX.Element;
// If no icon is specified, we show a deterministic
// colored circle to identify unique scripts.
if (logSource.icon) {
icon = (
<img
src={logSource.icon}
alt=""
className="size-3.5 mr-2 shrink-0"
/>
{nextChangesSource && (
<div
className="dashed-line"
css={(theme) => ({
height: 2,
width: "50%",
top: "calc(50% - 2px)",
left: "calc(50% - 1px)",
background: theme.experimental.l1.outline,
borderRadius: 2,
position: "absolute",
})}
/>
)}
</div>
);
}
);
} else {
icon = (
<div
role="presentation"
className="size-3.5 mr-2 shrink-0 rounded-full"
style={{
background: determineScriptDisplayColor(
logSource.display_name,
),
}}
/>
);
assignedIcon = true;
}
return (
<AgentLogLine
line={logs[index]}
number={index + 1}
maxLineNumber={logs.length}
style={style}
sourceIcon={
<Tooltip
title={
<>
{logSource.display_name}
{assignedIcon && (
<i>
<br />
No icon specified!
</i>
)}
</>
}
const doesNextLineHaveDifferentSource =
index < logs.length - 1 &&
getLogSource(logs[index + 1].sourceId).id !== log.sourceId;
// We don't want every line to repeat the icon, because
// that is ugly and repetitive. This removes the icon
// for subsequent lines of the same source and shows a
// line instead, visually indicating they are from the
// same source.
const shouldHideSource =
index > 0 &&
getLogSource(logs[index - 1].sourceId).id === log.sourceId;
if (shouldHideSource) {
icon = (
<div className="size-3.5 mr-2 flex justify-center relative shrink-0">
<div
// dashed-line class comes from AgentLogLine component
className={cn(
"dashed-line w-0.5 rounded-[2px] bg-surface-tertiary h-full",
doesNextLineHaveDifferentSource && "h-1/2",
)}
/>
{doesNextLineHaveDifferentSource && (
<div
role="presentation"
className="dashed-line h-[2px] w-1/2 top-[calc(50%-2px)] left-[calc(50%-1px)] rounded-[2px] absolute bg-surface-tertiary"
/>
)}
</div>
);
}
return (
<AgentLogLine
line={log}
number={index + 1}
maxLineNumber={logs.length}
style={style}
sourceIcon={
<MuiTooltip
title={
<>
{logSource.display_name}
{assignedIcon && (
<i>
<br />
No icon specified!
</i>
)}
</>
}
>
{icon}
</MuiTooltip>
}
/>
);
}}
</List>
{overflowed && (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Badge
asChild
className="max-w-fit py-1.5 px-3 absolute bottom-3 left-1/2 -translate-x-1/2"
>
{icon}
</Tooltip>
}
/>
);
}}
</List>
<span>Logs overflowed</span>
</Badge>
</TooltipTrigger>
<TooltipContent
asChild
className="w-full text-sm text-content-secondary bg-surface-primary max-w-prose leading-relaxed m-0 p-4"
>
<p>
Startup logs exceeded the max size of{" "}
<span className="tracking-wide font-mono">1MB</span>, and will
not continue to be written to the database. Logs will continue
to be written to the{" "}
<span className="font-mono bg-surface-tertiary rounded-md px-1.5 py-0.5">
/tmp/coder-startup-script.log
</span>{" "}
file in the workspace.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
},
);
// These colors were picked at random. Feel free
// to add more, adjust, or change! Users will not
// depend on these colors.
const scriptDisplayColors = [
// These colors were picked at random. Feel free to add more, adjust, or change!
// Users will not depend on these colors.
const scriptDisplayColors: readonly string[] = [
"#85A3B2",
"#A37EB2",
"#C29FDE",
@@ -191,15 +202,3 @@ const determineScriptDisplayColor = (displayName: string): string => {
}, 0);
return scriptDisplayColors[Math.abs(hash) % scriptDisplayColors.length];
};
const styles = {
logs: (theme) => ({
backgroundColor: theme.palette.background.paper,
paddingTop: 16,
// We need this to be able to apply the padding top from startupLogs
"& > div": {
position: "relative",
},
}),
} satisfies Record<string, Interpolation<Theme>>;
+6 -22
View File
@@ -19,7 +19,6 @@ import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
@@ -79,25 +78,9 @@ export const AgentRow: FC<AgentRowProps> = ({
["starting", "start_timeout"].includes(agent.lifecycle_state) &&
hasStartupFeatures,
);
const agentLogs = useAgentLogs(agent, showLogs);
const agentLogs = useAgentLogs({ agentId: agent.id, enabled: showLogs });
const logListRef = useRef<List>(null);
const logListDivRef = useRef<HTMLDivElement>(null);
const startupLogs = useMemo(() => {
const allLogs = agentLogs || [];
const logs = [...allLogs];
if (agent.logs_overflowed) {
logs.push({
id: -1,
level: "error",
output:
"Startup logs exceeded the max size of 1MB, and will not continue to be written to the database! Logs will continue to be written to the /tmp/coder-startup-script.log file in the workspace.",
created_at: new Date().toISOString(),
source_id: "",
});
}
return logs;
}, [agentLogs, agent.logs_overflowed]);
const [bottomOfLogs, setBottomOfLogs] = useState(true);
useEffect(() => {
@@ -109,9 +92,9 @@ export const AgentRow: FC<AgentRowProps> = ({
useLayoutEffect(() => {
// If we're currently watching the bottom, we always want to stay at the bottom.
if (bottomOfLogs && logListRef.current) {
logListRef.current.scrollToItem(startupLogs.length - 1, "end");
logListRef.current.scrollToItem(agentLogs.length - 1, "end");
}
}, [showLogs, startupLogs, bottomOfLogs]);
}, [showLogs, agentLogs, bottomOfLogs]);
// This is a bit of a hack on the react-window API to get the scroll position.
// If we're scrolled to the bottom, we want to keep the list scrolled to the bottom.
@@ -328,7 +311,8 @@ export const AgentRow: FC<AgentRowProps> = ({
width={width}
css={styles.startupLogs}
onScroll={handleLogScroll}
logs={startupLogs.map((l) => ({
overflowed={agent.logs_overflowed}
logs={agentLogs.map((l) => ({
id: l.id,
level: l.level,
output: l.output,
@@ -541,7 +525,7 @@ const styles = {
},
startupLogs: (theme) => ({
maxHeight: 256,
maxHeight: 420,
borderBottom: `1px solid ${theme.palette.divider}`,
backgroundColor: theme.palette.background.paper,
paddingTop: 16,
+180 -44
View File
@@ -1,60 +1,196 @@
import { MockWorkspaceAgent } from "testHelpers/entities";
import {
createMockWebSocket,
type MockWebSocketServer,
} from "testHelpers/websockets";
import { renderHook, waitFor } from "@testing-library/react";
import * as apiModule from "api/api";
import type { WorkspaceAgentLog } from "api/typesGenerated";
import WS from "jest-websocket-mock";
import * as snackbarUtils from "components/GlobalSnackbar/utils";
import { act } from "react";
import { OneWayWebSocket } from "utils/OneWayWebSocket";
import { useAgentLogs } from "./useAgentLogs";
/**
* TODO: WS does not support multiple tests running at once in isolation so we
* have one single test that test the most common scenario.
* Issue: https://github.com/romgain/jest-websocket-mock/issues/172
*/
const millisecondsInOneMinute = 60_000;
describe.skip("useAgentLogs", () => {
afterEach(() => {
WS.clean();
function generateMockLogs(
logCount: number,
baseDate = new Date("April 1, 1970"),
): readonly WorkspaceAgentLog[] {
return Array.from({ length: logCount }, (_, i) => {
// Make sure that the logs generated each have unique timestamps, so
// that we can test whether the hook is sorting them properly as it's
// receiving them over time
const logDate = new Date(baseDate.getTime() + i * millisecondsInOneMinute);
return {
id: i,
created_at: logDate.toISOString(),
level: "info",
output: `Log ${i}`,
source_id: "",
};
});
}
// A mutable object holding the most recent mock WebSocket server that was
// created when initializing a mock WebSocket. Inner value will be undefined if
// the hook is disabled on mount, but will always be defined otherwise
type ServerResult = { current: MockWebSocketServer | undefined };
type MountHookOptions = Readonly<{
initialAgentId: string;
enabled?: boolean;
}>;
type MountHookResult = Readonly<{
serverResult: ServerResult;
rerender: (props: { agentId: string; enabled: boolean }) => void;
displayError: jest.SpyInstance<void, [s1: string, s2?: string], unknown>;
// Note: the `current` property is only "halfway" readonly; the value is
// readonly, but the key is still mutable
hookResult: { current: readonly WorkspaceAgentLog[] };
}>;
function mountHook(options: MountHookOptions): MountHookResult {
const { initialAgentId, enabled = true } = options;
const serverResult: ServerResult = { current: undefined };
jest
.spyOn(apiModule, "watchWorkspaceAgentLogs")
.mockImplementation((agentId, params) => {
return new OneWayWebSocket({
apiRoute: `/api/v2/workspaceagents/${agentId}/logs`,
searchParams: new URLSearchParams({
follow: "true",
after: params?.after?.toString() || "0",
}),
websocketInit: (url) => {
const [mockSocket, mockServer] = createMockWebSocket(url);
serverResult.current = mockServer;
return mockSocket;
},
});
});
void jest.spyOn(console, "error").mockImplementation(() => {});
const displayError = jest.spyOn(snackbarUtils, "displayError");
const { result: hookResult, rerender } = renderHook(
(props) => useAgentLogs(props),
{ initialProps: { enabled, agentId: initialAgentId } },
);
return { rerender, serverResult, hookResult, displayError };
}
describe("useAgentLogs", () => {
it("Automatically sorts logs that are received out of order", async () => {
const { hookResult, serverResult } = mountHook({
initialAgentId: MockWorkspaceAgent.id,
});
const logs = generateMockLogs(10, new Date("september 9, 1999"));
const reversed = logs.toReversed();
for (const log of reversed) {
await act(async () => {
serverResult.current?.publishMessage(
new MessageEvent("message", { data: JSON.stringify([log]) }),
);
});
}
await waitFor(() => expect(hookResult.current).toEqual(logs));
});
it("clear logs when disabled to avoid duplicates", async () => {
const server = new WS(
`ws://localhost/api/v2/workspaceagents/${
MockWorkspaceAgent.id
}/logs?follow&after=0`,
);
const { result, rerender } = renderHook(
({ enabled }) => useAgentLogs(MockWorkspaceAgent, enabled),
{ initialProps: { enabled: true } },
);
await server.connected;
// Send 3 logs
server.send(JSON.stringify(generateLogs(3)));
await waitFor(() => {
expect(result.current).toHaveLength(3);
it("Never creates a connection if hook is disabled on mount", () => {
const { serverResult } = mountHook({
initialAgentId: MockWorkspaceAgent.id,
enabled: false,
});
// Disable the hook
rerender({ enabled: false });
await waitFor(() => {
expect(result.current).toHaveLength(0);
expect(serverResult.current).toBe(undefined);
});
it("Automatically closes the socket connection when the hook is disabled", async () => {
const { serverResult, rerender } = mountHook({
initialAgentId: MockWorkspaceAgent.id,
});
// Enable the hook again
rerender({ enabled: true });
await server.connected;
server.send(JSON.stringify(generateLogs(3)));
expect(serverResult.current?.isConnectionOpen).toBe(true);
rerender({ agentId: MockWorkspaceAgent.id, enabled: false });
await waitFor(() => {
expect(result.current).toHaveLength(3);
expect(serverResult.current?.isConnectionOpen).toBe(false);
});
});
it("Automatically closes the old connection when the agent ID changes", () => {
const { serverResult, rerender } = mountHook({
initialAgentId: MockWorkspaceAgent.id,
});
const serverConn1 = serverResult.current;
expect(serverConn1?.isConnectionOpen).toBe(true);
rerender({
enabled: true,
agentId: `${MockWorkspaceAgent.id}-new-value`,
});
const serverConn2 = serverResult.current;
expect(serverConn1).not.toBe(serverConn2);
expect(serverConn1?.isConnectionOpen).toBe(false);
expect(serverConn2?.isConnectionOpen).toBe(true);
});
it("Calls error callback when error is received (but only while hook is enabled)", async () => {
const { serverResult, rerender, displayError } = mountHook({
initialAgentId: MockWorkspaceAgent.id,
// Start off disabled so that we can check that the callback is
// never called when there is no connection
enabled: false,
});
const errorEvent = new Event("error");
await act(async () => serverResult.current?.publishError(errorEvent));
expect(displayError).not.toHaveBeenCalled();
rerender({ agentId: MockWorkspaceAgent.id, enabled: true });
await act(async () => serverResult.current?.publishError(errorEvent));
expect(displayError).toHaveBeenCalledTimes(1);
});
// This is a protection to avoid duplicate logs when the hook goes back to
// being re-enabled
it("Clears logs when hook becomes disabled", async () => {
const { hookResult, serverResult, rerender } = mountHook({
initialAgentId: MockWorkspaceAgent.id,
});
// Send initial logs so that we have something to clear out later
const initialLogs = generateMockLogs(3, new Date("april 5, 1997"));
const initialEvent = new MessageEvent("message", {
data: JSON.stringify(initialLogs),
});
await act(async () => serverResult.current?.publishMessage(initialEvent));
await waitFor(() => expect(hookResult.current).toEqual(initialLogs));
// Need to do the following steps multiple times to make sure that we
// don't break anything after the first disable
const mockDates: readonly string[] = ["october 3, 2005", "august 1, 2025"];
for (const md of mockDates) {
// Disable the hook to clear current logs
rerender({ agentId: MockWorkspaceAgent.id, enabled: false });
await waitFor(() => expect(hookResult.current).toHaveLength(0));
// Re-enable the hook and send new logs
rerender({ agentId: MockWorkspaceAgent.id, enabled: true });
const newLogs = generateMockLogs(3, new Date(md));
const newEvent = new MessageEvent("message", {
data: JSON.stringify(newLogs),
});
await act(async () => serverResult.current?.publishMessage(newEvent));
await waitFor(() => expect(hookResult.current).toEqual(newLogs));
}
});
});
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: "",
}));
}
+42 -14
View File
@@ -1,38 +1,66 @@
import { watchWorkspaceAgentLogs } from "api/api";
import type { WorkspaceAgent, WorkspaceAgentLog } from "api/typesGenerated";
import type { WorkspaceAgentLog } from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
import { useEffect, useState } from "react";
type UseAgentLogsOptions = Readonly<{
agentId: string;
enabled?: boolean;
}>;
export function useAgentLogs(
agent: WorkspaceAgent,
enabled: boolean,
options: UseAgentLogsOptions,
): readonly WorkspaceAgentLog[] {
const [logs, setLogs] = useState<WorkspaceAgentLog[]>([]);
const { agentId, enabled = true } = options;
const [logs, setLogs] = useState<readonly WorkspaceAgentLog[]>([]);
// Clean up the logs when the agent is not enabled, using a mid-render
// sync to remove any risk of screen flickering. Clearing the logs helps
// ensure that if the hook flips back to being enabled, we can receive a
// fresh set of logs from the beginning with zero risk of duplicates.
const [prevEnabled, setPrevEnabled] = useState(enabled);
if (!enabled && prevEnabled) {
setLogs([]);
setPrevEnabled(false);
}
if (enabled && !prevEnabled) {
setPrevEnabled(true);
}
useEffect(() => {
if (!enabled) {
// Clean up the logs when the agent is not enabled. So it can receive logs
// from the beginning without duplicating the logs.
setLogs([]);
return;
}
// Always fetch the logs from the beginning. We may want to optimize this in
// the future, but it would add some complexity in the code that maybe does
// not worth it.
const socket = watchWorkspaceAgentLogs(agent.id, { after: 0 });
// Always fetch the logs from the beginning. We may want to optimize
// this in the future, but it would add some complexity in the code
// that might not be worth it.
const socket = watchWorkspaceAgentLogs(agentId, { after: 0 });
socket.addEventListener("message", (e) => {
if (e.parseError) {
console.warn("Error parsing agent log: ", e.parseError);
return;
}
setLogs((logs) => [...logs, ...e.parsedMessage]);
if (e.parsedMessage.length === 0) {
return;
}
setLogs((logs) => {
const newLogs = [...logs, ...e.parsedMessage];
newLogs.sort((l1, l2) => {
const d1 = new Date(l1.created_at).getTime();
const d2 = new Date(l2.created_at).getTime();
return d1 - d2;
});
return newLogs;
});
});
socket.addEventListener("error", (e) => {
console.error("Error in agent log socket: ", e);
displayError(
"Unable to watch the agent logs",
"Unable to watch agent logs",
"Please try refreshing the browser",
);
socket.close();
@@ -41,7 +69,7 @@ export function useAgentLogs(
return () => {
socket.close();
};
}, [agent.id, enabled]);
}, [agentId, enabled]);
return logs;
}
+6 -5
View File
@@ -247,7 +247,7 @@ type TaskStartingAgentProps = {
};
const TaskStartingAgent: FC<TaskStartingAgentProps> = ({ agent }) => {
const logs = useAgentLogs(agent, true);
const logs = useAgentLogs({ agentId: agent.id });
const listRef = useRef<FixedSizeList>(null);
useLayoutEffect(() => {
@@ -272,6 +272,11 @@ const TaskStartingAgent: FC<TaskStartingAgentProps> = ({ agent }) => {
<div className="w-full max-w-screen-lg flex flex-col gap-4 overflow-hidden">
<div className="h-96 border border-solid border-border rounded-lg">
<AgentLogs
ref={listRef}
sources={agent.log_sources}
height={96 * 4}
width="100%"
overflowed={agent.logs_overflowed}
logs={logs.map((l) => ({
id: l.id,
level: l.level,
@@ -279,10 +284,6 @@ const TaskStartingAgent: FC<TaskStartingAgentProps> = ({ agent }) => {
sourceId: l.source_id,
time: l.created_at,
}))}
sources={agent.log_sources}
height={96 * 4}
width="100%"
ref={listRef}
/>
</div>
</div>
@@ -6,7 +6,6 @@ import type {
import { Alert } from "components/Alert/Alert";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import type { Line } from "components/Logs/LogLine";
import { Margins } from "components/Margins/Margins";
import {
FullWidthPageHeader,
@@ -293,24 +292,20 @@ type AgentLogsContentProps = {
};
const AgentLogsContent: FC<AgentLogsContentProps> = ({ agent }) => {
const logs = useAgentLogs(agent, true);
if (!logs) {
return <Loader />;
}
const logs = useAgentLogs({ agentId: agent.id });
return (
<AgentLogs
overflowed={agent.logs_overflowed}
sources={agent.log_sources}
logs={logs.map<Line>((l) => ({
height={560}
width="100%"
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%"
/>
);
};
+2
View File
@@ -58,6 +58,8 @@ module.exports = {
border: {
DEFAULT: "hsl(var(--border-default))",
warning: "hsl(var(--border-warning))",
green: "hsl(var(--border-green))",
sky: "hsl(var(--border-sky))",
destructive: "hsl(var(--border-destructive))",
success: "hsl(var(--border-success))",
hover: "hsl(var(--border-hover))",