mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
fix(site): show startup script failure message without restart suggestion (#24449)
When a workspace agent's startup script fails, restarting the workspace will not resolve the issue since the script will keep failing. Previously all unhealthy workspaces showed the same generic notification with a Restart button regardless of cause. Now, when every failing agent has `lifecycle_state=start_error`, the workspace-level notification shows "A startup script has failed" and guides the user to contact their template admin instead of offering a Restart action. > Code written by Claude 🤖 reviewed by yours truly --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
ef2b3a7263
commit
b4eb0e20e5
@@ -98,6 +98,12 @@ const installScriptLogSource: WorkspaceAgentLogSource = {
|
||||
display_name: "Install Script",
|
||||
};
|
||||
|
||||
const startupScriptLogSource: WorkspaceAgentLogSource = {
|
||||
...M.MockWorkspaceAgentLogSource,
|
||||
id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
display_name: "Startup Script",
|
||||
};
|
||||
|
||||
const tabbedLogs = [
|
||||
{
|
||||
id: 100,
|
||||
@@ -229,6 +235,61 @@ export const StartError: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const StartErrorWithTimings: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const scriptTab = await canvas.findByRole("tab", {
|
||||
name: "Startup Script",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(scriptTab).toHaveAttribute("data-state", "active"),
|
||||
);
|
||||
},
|
||||
args: {
|
||||
agent: {
|
||||
...M.MockWorkspaceAgentStartError,
|
||||
logs_length: 2,
|
||||
log_sources: [startupScriptLogSource],
|
||||
},
|
||||
agentScriptTimings: [
|
||||
{
|
||||
display_name: "Startup Script",
|
||||
exit_code: 1,
|
||||
stage: "start",
|
||||
status: "exit_failure",
|
||||
started_at: "2021-05-05T00:00:00.000Z",
|
||||
ended_at: "2021-05-05T00:00:01.000Z",
|
||||
workspace_agent_id: M.MockWorkspaceAgentStartError.id,
|
||||
workspace_agent_name: M.MockWorkspaceAgentStartError.name,
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
webSocket: [
|
||||
{
|
||||
event: "message",
|
||||
data: JSON.stringify([
|
||||
{
|
||||
id: 200,
|
||||
level: "info",
|
||||
output: "startup: preparing workspace",
|
||||
source_id: M.MockWorkspaceAgentLogSource.id,
|
||||
created_at: fixedLogTimestamp,
|
||||
},
|
||||
{
|
||||
id: 201,
|
||||
level: "error",
|
||||
output: "startup script: command not found",
|
||||
source_id: startupScriptLogSource.id,
|
||||
created_at: fixedLogTimestamp,
|
||||
},
|
||||
]),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const ShuttingDown: Story = {
|
||||
args: {
|
||||
agent: M.MockWorkspaceAgentShuttingDown,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Link as RouterLink } from "react-router";
|
||||
import AutoSizer from "react-virtualized-auto-sizer";
|
||||
import type { FixedSizeList as List, ListOnScrollProps } from "react-window";
|
||||
import type {
|
||||
AgentScriptTiming,
|
||||
Template,
|
||||
Workspace,
|
||||
WorkspaceAgent,
|
||||
@@ -47,8 +48,14 @@ import { useKebabMenu } from "#/components/Tabs/utils/useKebabMenu";
|
||||
import { useProxy } from "#/contexts/ProxyContext";
|
||||
import { useClipboard } from "#/hooks/useClipboard";
|
||||
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
|
||||
import { getAgentHealthIssues } from "#/modules/workspaces/health";
|
||||
import { AgentAlert } from "#/pages/WorkspacePage/AgentAlert";
|
||||
import {
|
||||
agentScriptMessages,
|
||||
getAgentHealthIssues,
|
||||
} from "#/modules/workspaces/health";
|
||||
import {
|
||||
AgentAlert,
|
||||
StartScriptFailureDetail,
|
||||
} from "#/pages/WorkspacePage/AgentAlert";
|
||||
import { AppStatuses } from "#/pages/WorkspacePage/AppStatuses";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps";
|
||||
@@ -75,6 +82,7 @@ interface AgentRowProps {
|
||||
workspace: Workspace;
|
||||
template: Template;
|
||||
initialMetadata?: WorkspaceAgentMetadata[];
|
||||
agentScriptTimings?: readonly AgentScriptTiming[];
|
||||
onUpdateAgent: () => void;
|
||||
}
|
||||
|
||||
@@ -124,6 +132,7 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
template,
|
||||
onUpdateAgent,
|
||||
initialMetadata,
|
||||
agentScriptTimings,
|
||||
}) => {
|
||||
const { browser_only, workspace_external_agent } = useFeatureVisibility();
|
||||
const appSections = organizeAgentApps(agent.apps);
|
||||
@@ -141,6 +150,12 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
const hasStartupFeatures = Boolean(agent.logs_length);
|
||||
const healthIssues = getAgentHealthIssues(agent);
|
||||
const hasAgentIssues = healthIssues.length > 0;
|
||||
const failedStartTimings = agentScriptTimings?.filter(
|
||||
(t) =>
|
||||
t.workspace_agent_id === agent.id &&
|
||||
t.stage === "start" &&
|
||||
t.exit_code !== 0,
|
||||
);
|
||||
const { proxy } = useProxy();
|
||||
const [showLogs, setShowLogs] = useState(
|
||||
(["starting", "start_timeout"].includes(agent.lifecycle_state) ||
|
||||
@@ -220,7 +235,14 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
agent,
|
||||
Boolean(hasDevcontainerErrors || shouldShowWildcardWarning),
|
||||
);
|
||||
const [selectedLogTab, setSelectedLogTab] = useState("all");
|
||||
const failedStartupScriptSource = hasAgentIssues
|
||||
? agent.log_sources.find(
|
||||
(s) => s.display_name === STARTUP_SCRIPT_DISPLAY_NAME,
|
||||
)
|
||||
: undefined;
|
||||
const [selectedLogTab, setSelectedLogTab] = useState(
|
||||
failedStartupScriptSource?.id ?? "all",
|
||||
);
|
||||
const sourceLogTabs = agent.log_sources
|
||||
.filter((logSource) => {
|
||||
// Remove the logSources that have no entries.
|
||||
@@ -482,13 +504,27 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
<div className={cn("px-4", hasStartupFeatures ? "pb-4" : "py-4")}>
|
||||
{healthIssues.length > 0 && (
|
||||
<div className="mb-4 flex flex-col gap-3">
|
||||
{healthIssues.map((issue) => (
|
||||
<AgentAlert
|
||||
key={`${issue.title}-${issue.detail}`}
|
||||
{...issue}
|
||||
troubleshootingURL={agent.troubleshooting_url}
|
||||
/>
|
||||
))}
|
||||
{healthIssues.map((issue) => {
|
||||
const isStartError =
|
||||
issue.title === agentScriptMessages.start_error.title;
|
||||
const detail =
|
||||
isStartError && failedStartTimings?.length ? (
|
||||
<StartScriptFailureDetail
|
||||
baseDetail={issue.detail}
|
||||
timings={failedStartTimings}
|
||||
/>
|
||||
) : (
|
||||
issue.detail
|
||||
);
|
||||
return (
|
||||
<AgentAlert
|
||||
key={`${issue.title}-${issue.detail}`}
|
||||
{...issue}
|
||||
detail={detail}
|
||||
troubleshootingURL={agent.troubleshooting_url}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{hasStartupFeatures && hasAnyLogs && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AgentAlert } from "./AgentAlert";
|
||||
import { AgentAlert, StartScriptFailureDetail } from "./AgentAlert";
|
||||
|
||||
const meta: Meta<typeof AgentAlert> = {
|
||||
title: "pages/WorkspacePage/AgentAlert",
|
||||
@@ -50,3 +50,34 @@ export const WithoutTroubleshootingURL: Story = {
|
||||
troubleshootingURL: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithScriptTimingDetail: Story = {
|
||||
render: (args) => (
|
||||
<AgentAlert
|
||||
{...args}
|
||||
detail={
|
||||
<StartScriptFailureDetail
|
||||
baseDetail="A startup script exited with an error. Check the agent logs for details."
|
||||
timings={[
|
||||
{
|
||||
display_name: "Startup Script",
|
||||
exit_code: 1,
|
||||
stage: "start",
|
||||
status: "exit_failure",
|
||||
started_at: "2021-05-05T00:00:00.000Z",
|
||||
ended_at: "2021-05-05T00:00:01.000Z",
|
||||
workspace_agent_id: "test-agent-id",
|
||||
workspace_agent_name: "test-agent",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
),
|
||||
args: {
|
||||
title: "Startup script failed",
|
||||
severity: "warning",
|
||||
prominent: false,
|
||||
troubleshootingURL: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,15 +1,39 @@
|
||||
import type { FC } from "react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import type { AgentScriptTiming } from "#/api/typesGenerated";
|
||||
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
|
||||
interface AgentAlertProps {
|
||||
title: string;
|
||||
detail: string;
|
||||
detail: ReactNode;
|
||||
severity: "info" | "warning";
|
||||
prominent: boolean;
|
||||
troubleshootingURL?: string;
|
||||
}
|
||||
|
||||
interface StartScriptFailureDetailProps {
|
||||
baseDetail: string;
|
||||
timings: readonly AgentScriptTiming[];
|
||||
}
|
||||
|
||||
export const StartScriptFailureDetail: FC<StartScriptFailureDetailProps> = ({
|
||||
baseDetail,
|
||||
timings,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{baseDetail}
|
||||
<ul className="mt-2 mb-0 pl-0 list-none space-y-0.5">
|
||||
{timings.map((t) => (
|
||||
<li key={t.display_name} className="font-mono text-xs">
|
||||
“{t.display_name}” exited with code {t.exit_code}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AgentAlert: FC<AgentAlertProps> = ({
|
||||
title,
|
||||
detail,
|
||||
|
||||
@@ -219,6 +219,7 @@ export const Workspace: FC<WorkspaceProps> = ({
|
||||
)}
|
||||
workspace={workspace}
|
||||
template={template}
|
||||
agentScriptTimings={timings?.agent_script_timings}
|
||||
onUpdateAgent={handleUpdate} // On updating the workspace the agent version is also updated
|
||||
/>
|
||||
))}
|
||||
|
||||
+7
-2
@@ -152,14 +152,19 @@ export const StartupScriptFailed: Story = {
|
||||
},
|
||||
|
||||
play: async ({ step }) => {
|
||||
await step("activate hover trigger", async () => {
|
||||
await step("shows startup script failure message", async () => {
|
||||
await userEvent.hover(screen.getByTestId("warning-notifications"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("tooltip")).toHaveTextContent(
|
||||
/one or more workspace agents need attention/i,
|
||||
/a startup script has failed/i,
|
||||
),
|
||||
);
|
||||
});
|
||||
await step("does not offer restart", async () => {
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /restart/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -90,28 +90,47 @@ export const WorkspaceNotifications: FC<WorkspaceNotificationsProps> = ({
|
||||
!workspace.health.healthy
|
||||
) {
|
||||
const troubleshootingURL = findTroubleshootingURL(workspace.latest_build);
|
||||
const hasActions = permissions.updateWorkspace || troubleshootingURL;
|
||||
notifications.push({
|
||||
title: "One or more workspace agents need attention",
|
||||
severity: "warning",
|
||||
detail: "Expand an agent's logs to view per-agent health details.",
|
||||
actions: hasActions ? (
|
||||
<>
|
||||
{permissions.updateWorkspace && (
|
||||
<NotificationActionButton onClick={onRestartWorkspace}>
|
||||
Restart
|
||||
</NotificationActionButton>
|
||||
)}
|
||||
{troubleshootingURL && (
|
||||
<NotificationActionButton
|
||||
onClick={() => window.open(troubleshootingURL, "_blank")}
|
||||
>
|
||||
Troubleshooting
|
||||
</NotificationActionButton>
|
||||
)}
|
||||
</>
|
||||
) : undefined,
|
||||
});
|
||||
|
||||
if (isStartupScriptFailure(workspace)) {
|
||||
// Restarting won't fix a broken startup script, so omit the Restart
|
||||
// button and guide the user to their template admin instead.
|
||||
notifications.push({
|
||||
title: "A startup script has failed",
|
||||
severity: "warning",
|
||||
detail:
|
||||
"The workspace agent is running but a startup script exited with an error.",
|
||||
actions: troubleshootingURL ? (
|
||||
<NotificationActionButton
|
||||
onClick={() => window.open(troubleshootingURL, "_blank")}
|
||||
>
|
||||
Troubleshooting
|
||||
</NotificationActionButton>
|
||||
) : undefined,
|
||||
});
|
||||
} else {
|
||||
const hasActions = permissions.updateWorkspace || troubleshootingURL;
|
||||
notifications.push({
|
||||
title: "One or more workspace agents need attention",
|
||||
severity: "warning",
|
||||
detail: "Expand an agent's logs to view per-agent health details.",
|
||||
actions: hasActions ? (
|
||||
<>
|
||||
{permissions.updateWorkspace && (
|
||||
<NotificationActionButton onClick={onRestartWorkspace}>
|
||||
Restart
|
||||
</NotificationActionButton>
|
||||
)}
|
||||
{troubleshootingURL && (
|
||||
<NotificationActionButton
|
||||
onClick={() => window.open(troubleshootingURL, "_blank")}
|
||||
>
|
||||
Troubleshooting
|
||||
</NotificationActionButton>
|
||||
)}
|
||||
</>
|
||||
) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Dormant
|
||||
@@ -272,3 +291,24 @@ const findTroubleshootingURL = (
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true when every failing agent's lifecycle state is "start_error",
|
||||
* meaning the agent process is running but a startup script exited with an
|
||||
* error. Restarting the workspace will not fix this because the template admin
|
||||
* must correct the startup script.
|
||||
*/
|
||||
const isStartupScriptFailure = (workspace: Workspace): boolean => {
|
||||
const failingIds = new Set(workspace.health.failing_agents);
|
||||
if (failingIds.size === 0) {
|
||||
return false;
|
||||
}
|
||||
for (const resource of workspace.latest_build.resources) {
|
||||
for (const agent of resource.agents ?? []) {
|
||||
if (failingIds.has(agent.id) && agent.lifecycle_state !== "start_error") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user