Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 34 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
d6e00c3
wip: commit progress on test update
Parkreiner May 23, 2025
43d0ca8
refactor: update useAgentLogs tests as unit tests
Parkreiner May 23, 2025
727bddd
docs: rewrite comment for clarity
Parkreiner May 23, 2025
d46d144
fix: remove unnecessary type
Parkreiner May 23, 2025
91a6fc1
fix: make sure logs have different timestamps
Parkreiner May 23, 2025
ecbe7b0
fix: add different dates to reduce risk of false positives
Parkreiner May 23, 2025
d13bcdc
Merge branch 'main' into mes/logs-flake
Parkreiner May 23, 2025
0f21097
Merge branch 'main' into mes/logs-flake
Parkreiner Aug 2, 2025
abd6553
refactor: decrease coupling
Parkreiner Aug 2, 2025
bade97a
wip: commit progress on updating flake
Parkreiner Aug 2, 2025
e11fefd
Merge branch 'mes/logs-flake' of https://github.com/coder/coder into …
Parkreiner Aug 2, 2025
bc3d095
fix: get all tests passing
Parkreiner Aug 2, 2025
550d09e
chore: add one more test case
Parkreiner Aug 2, 2025
cc7e632
fix: update type mismatches
Parkreiner Aug 2, 2025
79c7ffd
refactor: clean up some code
Parkreiner Aug 2, 2025
43a0d3a
fix: make testing boundaries more formal
Parkreiner Aug 2, 2025
982d3e1
fix: remove premature optimization
Parkreiner Aug 2, 2025
41c5a12
fix: update setup
Parkreiner Aug 4, 2025
42cb73b
fix: update state sync logic
Parkreiner Aug 4, 2025
3a5f7bb
Merge branch 'main' into mes/logs-flake
Parkreiner Aug 4, 2025
35a40df
fix: update wonky types
Parkreiner Aug 4, 2025
306dbc7
Merge branch 'main' into mes/logs-flake
Parkreiner Aug 4, 2025
f49e55a
Merge branch 'main' into mes/logs-flake
Parkreiner Aug 7, 2025
c2fc772
fix: update tests
Parkreiner Aug 7, 2025
2cabd85
fix: format
Parkreiner Aug 7, 2025
855f3ca
Merge branch 'main' into mes/logs-flake
Parkreiner Aug 9, 2025
453894b
fix: apply initial feedback
Parkreiner Aug 9, 2025
c9f2b12
wip: commit refactoring progress
Parkreiner Aug 9, 2025
80865fe
refactor: update assignment
Parkreiner Aug 9, 2025
f930b29
wip: prepare to change indents
Parkreiner Aug 9, 2025
a311ac9
fix: update keygen logic
Parkreiner Aug 9, 2025
5657536
chore: add basic overflow message
Parkreiner Aug 9, 2025
6547a2f
chore: swap to tailwind
Parkreiner Aug 9, 2025
c818aec
wip: commit progress
Parkreiner Aug 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ export const watchWorkspaceAgentLogs = (
});
};

type WatchWorkspaceAgentLogsParams = {
export type WatchWorkspaceAgentLogsParams = {
after?: number;
};

Expand Down
3 changes: 1 addition & 2 deletions site/src/hooks/useEmbeddedMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Copy link
Member Author

@Parkreiner Parkreiner Aug 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A case where realistically, the value of manager won't ever change, but it technically can, since function parameters are reassignable. So better to add it to the dependencies for correctness


return stableMetadataResult;
};
Expand Down
9 changes: 7 additions & 2 deletions site/src/modules/resources/AgentLogs/AgentLogs.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -19,6 +20,10 @@ 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: {
overflowed: true,
},
};
294 changes: 137 additions & 157 deletions site/src/modules/resources/AgentLogs/AgentLogs.tsx
Original file line number Diff line number Diff line change
@@ -1,178 +1,170 @@
import type { Interpolation, Theme } from "@emotion/react";
import Tooltip from "@mui/material/Tooltip";
import type { WorkspaceAgentLogSource } from "api/typesGenerated";
import type { Line } from "components/Logs/LogLine";
import { type ComponentProps, forwardRef, useMemo } from "react";
import { type ComponentProps, forwardRef } from "react";
import { FixedSizeList as List } from "react-window";
import { cn } from "utils/cn";
import { AGENT_LOG_LINE_HEIGHT, AgentLogLine } from "./AgentLogLine";

const fallbackLog: WorkspaceAgentLogSource = {
created_at: "",
display_name: "Logs",
icon: "",
id: "00000000-0000-0000-0000-000000000000",
workspace_agent_id: "",
};

function groupLogSourcesById(
sources: readonly WorkspaceAgentLogSource[],
): Record<string, WorkspaceAgentLogSource> {
const sourcesById: Record<string, WorkspaceAgentLogSource> = {};
for (const source of sources) {
sourcesById[source.id] = source;
}
return sourcesById;
}

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, ...listProps }, ref) => {
// getLogSource must always returns a valid log source. We need this to
// support deployments that were made before `coder_script` was created
// and that haven't updated to a newer Coder version yet
const logSourceByID = groupLogSourcesById(sources);
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="flex flex-col gap-2">
{overflowed && (
<p className="text-sm w-full text-content-secondary bg-content-tertiary max-w-prose pl-4 m-0 pt-2.5 pb-1">
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>
)}

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;
}
<List
{...listProps}
ref={ref}
// We need the div selector to be able to apply the padding
// top from startupLogs
className="pt-4 [&>div]:relative bg-surface-secondary"
itemCount={logs.length}
itemSize={AGENT_LOG_LINE_HEIGHT}
itemKey={(index) => logs[index].id || 0}
>
{({ index, style }) => {
const log = logs[index];
const logSource = getLogSource(log.sourceId);

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,
}}
>
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"
/>
);
} else {
icon = (
<div
className="dashed-line"
css={(theme) => ({
height: nextChangesSource ? "50%" : "100%",
width: 2,
background: theme.experimental.l1.outline,
borderRadius: 2,
})}
role="presentation"
className="size-3.5 mr-2 shrink-0 rounded-full"
style={{
background: determineScriptDisplayColor(
logSource.display_name,
),
}}
/>
{nextChangesSource && (
);
assignedIcon = true;
}

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
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",
})}
// dashed-line class comes from LogLine
className={cn(
"dashed-line w-0.5 rounded-[2px] bg-surface-tertiary h-full",
doesNextLineHaveDifferentSource && "h-1/2",
)}
/>
)}
</div>
);
}
{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={logs[index]}
number={index + 1}
maxLineNumber={logs.length}
style={style}
sourceIcon={
<Tooltip
title={
<>
{logSource.display_name}
{assignedIcon && (
<i>
<br />
No icon specified!
</i>
)}
</>
}
>
{icon}
</Tooltip>
}
/>
);
}}
</List>
return (
<AgentLogLine
line={log}
number={index + 1}
maxLineNumber={logs.length}
style={style}
sourceIcon={
<Tooltip
title={
<>
{logSource.display_name}
{assignedIcon && (
<i>
<br />
No icon specified!
</i>
)}
</>
}
>
{icon}
</Tooltip>
}
/>
);
}}
</List>
</div>
);
},
);

// These colors were picked at random. Feel free
// to add more, adjust, or change! Users will not
// depend on these colors.
const scriptDisplayColors = [
const scriptDisplayColors: readonly string[] = [
"#85A3B2",
"#A37EB2",
"#C29FDE",
Expand All @@ -191,15 +183,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>>;
Loading
Loading