diff --git a/site/src/modules/resources/AgentRow.stories.tsx b/site/src/modules/resources/AgentRow.stories.tsx index a8213ae5d1..30002d60a8 100644 --- a/site/src/modules/resources/AgentRow.stories.tsx +++ b/site/src/modules/resources/AgentRow.stories.tsx @@ -189,6 +189,42 @@ export const Connecting: Story = { }, }; +export const ConnectingWithStartupLogs: Story = { + args: { + agent: { + ...M.MockWorkspaceAgentConnecting, + logs_length: 1, + }, + initialMetadata: [], + }, + parameters: { + webSocket: [ + { + event: "message", + data: JSON.stringify([ + { + id: 1, + level: "info", + output: "starting up", + source_id: M.MockWorkspaceAgentLogSource.id, + created_at: fixedLogTimestamp, + }, + ]), + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Agent is connecting (hasAgentIssues=true) but no script has failed. + // Old code snapped to the Startup Script tab; the fix keeps us on All Logs. + const allLogsTab = await canvas.findByRole("tab", { name: "All Logs" }); + await waitFor(() => + expect(allLogsTab).toHaveAttribute("data-state", "active"), + ); + }, +}; + export const Timeout: Story = { args: { agent: M.MockWorkspaceAgentTimeout, @@ -257,6 +293,135 @@ export const StartError: Story = { }, ], }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // MockWorkspaceAgentStartError ships with a Startup Script whose script + // has exit_code: 1, so the auto-select should land us there. + const startupScriptTab = await canvas.findByRole("tab", { + name: "Startup Script", + }); + await waitFor(() => + expect(startupScriptTab).toHaveAttribute("data-state", "active"), + ); + }, +}; + +export const StartErrorWithoutFailedSourceLogs: Story = { + args: { + agent: M.MockWorkspaceAgentStartError, + }, + parameters: { + // Send log entries only for the OK script, mirroring the case where a + // failed script never emitted any output. The selected tab must not be + // initialized to a source that has no rendered tab. + webSocket: [ + { + event: "message", + data: JSON.stringify( + M.MockWorkspaceAgentStartError.log_sources + .filter((source) => { + const script = M.MockWorkspaceAgentStartError.scripts.find( + (s) => s.log_source_id === source.id, + ); + return !script?.exit_code && script?.status === "ok"; + }) + .flatMap((source, i) => [ + { + id: i, + level: "info", + output: `output from '${source.display_name}'`, + source_id: source.id, + created_at: fixedLogTimestamp, + }, + ]), + ), + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Wait for a non-failed source tab to render, confirming logs streamed in. + await canvas.findByRole("tab", { name: "coder" }); + + // All Logs must stay active because no failed source has rendered logs. + const allLogsTab = canvas.getByRole("tab", { name: "All Logs" }); + await waitFor(() => + expect(allLogsTab).toHaveAttribute("data-state", "active"), + ); + }, +}; + +const NON_STARTUP_SCRIPT_SOURCE_ID = "install-script-source-id"; + +export const NonStartupScriptError: Story = { + args: { + agent: { + ...M.MockWorkspaceAgent, + logs_length: 2, + scripts: [ + // Startup Script succeeded. + { + ...M.MockWorkspaceAgent.scripts[0], + exit_code: 0, + status: "ok", + }, + // A non-startup script failed; that's the tab we should auto-select. + { + ...M.MockWorkspaceAgent.scripts[0], + id: "install-script-id", + log_source_id: NON_STARTUP_SCRIPT_SOURCE_ID, + exit_code: 1, + status: "exit_failure", + display_name: "Install Script", + }, + ], + log_sources: [ + ...M.MockWorkspaceAgent.log_sources, + { + ...M.MockWorkspaceAgent.log_sources[0], + id: NON_STARTUP_SCRIPT_SOURCE_ID, + display_name: "Install Script", + }, + ], + }, + }, + parameters: { + webSocket: [ + { + event: "message", + data: JSON.stringify([ + { + id: 1, + level: "info", + output: "startup ok", + source_id: M.MockWorkspaceAgentLogSource.id, + created_at: fixedLogTimestamp, + }, + { + id: 2, + level: "error", + output: "install failed", + source_id: NON_STARTUP_SCRIPT_SOURCE_ID, + created_at: fixedLogTimestamp, + }, + ]), + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Startup Script is OK; only Install Script failed. The auto-select must + // follow the failure, not the position or display name. + const installScriptTab = await canvas.findByRole("tab", { + name: "Install Script", + }); + await waitFor(() => + expect(installScriptTab).toHaveAttribute("data-state", "active"), + ); + }, }; export const ShuttingDown: Story = { diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 46c306613e..5dd010ec9f 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -25,6 +25,7 @@ import type { Workspace, WorkspaceAgent, WorkspaceAgentMetadata, + WorkspaceAgentScript, } from "#/api/typesGenerated"; import { CheckIcon } from "#/components/AnimatedIcons/Check"; import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown"; @@ -128,6 +129,13 @@ const getAgentBorderClass = ( const STARTUP_SCRIPT_DISPLAY_NAME = "Startup Script"; +// A script is considered failed if it exited with a non-zero code, or if its +// status reports a known failure mode (anything other than "ok"). Kept aligned +// with the per-tab error indicator so the auto-selected tab matches the visual +// warning badge. +const isScriptFailed = (script: WorkspaceAgentScript | undefined): boolean => + Boolean(script?.exit_code || (script?.status && script.status !== "ok")); + export const AgentRow: FC = ({ agent, subAgents, @@ -235,14 +243,34 @@ export const AgentRow: FC = ({ agent, Boolean(hasDevcontainerErrors || shouldShowWildcardWarning), ); - const failedStartupScriptSource = hasAgentIssues - ? agent.log_sources.find( - (s) => s.display_name === STARTUP_SCRIPT_DISPLAY_NAME, - ) - : undefined; - const [selectedLogTab, setSelectedLogTab] = useState( - failedStartupScriptSource?.id ?? "all", - ); + const [selectedLogTab, setSelectedLogTab] = useState("all"); + const hasAutoSelectedLogTabRef = useRef(false); + // Auto-select the first log tab whose script failed and has rendered output. + useEffect(() => { + if (hasAutoSelectedLogTabRef.current) { + return; + } + const failedSourceWithLogs = agent.log_sources.find((logSource) => { + const script = agent.scripts.find( + (s) => s.log_source_id === logSource.id, + ); + if (!isScriptFailed(script)) { + return false; + } + return agentLogs.some( + (log) => + log.source_id === logSource.id && (log.output?.length ?? 0) > 0, + ); + }); + if (failedSourceWithLogs) { + hasAutoSelectedLogTabRef.current = true; + setSelectedLogTab(failedSourceWithLogs.id); + } + }, [agent.log_sources, agent.scripts, agentLogs]); + const handleSelectedLogTabChange = (value: string) => { + hasAutoSelectedLogTabRef.current = true; + setSelectedLogTab(value); + }; const sortedSourceLogTabs = agent.log_sources .filter((logSource) => { // Remove the logSources that have no entries. @@ -269,9 +297,7 @@ export const AgentRow: FC = ({ ) : null, title: logSource.display_name, value: logSource.id, - error: Boolean( - script?.exit_code || (script?.status && script.status !== "ok"), - ), + error: isScriptFailed(script), }; }) .sort((a, b) => { @@ -563,7 +589,7 @@ export const AgentRow: FC = ({
@@ -621,7 +647,7 @@ export const AgentRow: FC = ({ {overflowLogTabs.map((tab) => (