mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
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:
Vendored
+1
@@ -54,6 +54,7 @@
|
||||
}
|
||||
},
|
||||
|
||||
"tailwindCSS.classFunctions": ["cva", "cn"],
|
||||
"[css][html][markdown][yaml]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "",
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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%"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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))",
|
||||
|
||||
Reference in New Issue
Block a user