Skip to content

Commit aebcafd

Browse files
committed
feat(site): allow recreating devcontainers and showing dirty status
This change allows showing the devcontainer dirty status in the UI as well as a recreate button to update the devcontainer. Closes #16424
1 parent afaa20e commit aebcafd

File tree

6 files changed

+111
-23
lines changed

6 files changed

+111
-23
lines changed

agent/agent_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2226,7 +2226,7 @@ func TestAgent_DevcontainerRecreate(t *testing.T) {
22262226
// devcontainer, we do it in a goroutine so we can process logs
22272227
// concurrently.
22282228
go func(container codersdk.WorkspaceAgentContainer) {
2229-
err := conn.RecreateDevcontainer(ctx, container.ID)
2229+
_, err := conn.RecreateDevcontainer(ctx, container.ID)
22302230
assert.NoError(t, err, "recreate devcontainer should succeed")
22312231
}(container)
22322232

coderd/workspaceagents.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -956,7 +956,7 @@ func (api *API) workspaceAgentRecreateDevcontainer(rw http.ResponseWriter, r *ht
956956
}
957957
defer release()
958958

959-
err = agentConn.RecreateDevcontainer(ctx, container)
959+
m, err := agentConn.RecreateDevcontainer(ctx, container)
960960
if err != nil {
961961
if errors.Is(err, context.Canceled) {
962962
httpapi.Write(ctx, rw, http.StatusRequestTimeout, codersdk.Response{
@@ -977,7 +977,7 @@ func (api *API) workspaceAgentRecreateDevcontainer(rw http.ResponseWriter, r *ht
977977
return
978978
}
979979

980-
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
980+
httpapi.Write(ctx, rw, http.StatusAccepted, m)
981981
}
982982

983983
// @Summary Get connection info for workspace agent

coderd/workspaceagents_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1483,7 +1483,7 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) {
14831483

14841484
ctx := testutil.Context(t, testutil.WaitLong)
14851485

1486-
err := client.WorkspaceAgentRecreateDevcontainer(ctx, agentID, devContainer.ID)
1486+
_, err := client.WorkspaceAgentRecreateDevcontainer(ctx, agentID, devContainer.ID)
14871487
if wantStatus > 0 {
14881488
cerr, ok := codersdk.AsError(err)
14891489
require.True(t, ok, "expected error to be a coder error")

codersdk/workspaceagents.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -518,16 +518,20 @@ func (c *Client) WorkspaceAgentListContainers(ctx context.Context, agentID uuid.
518518
}
519519

520520
// WorkspaceAgentRecreateDevcontainer recreates the devcontainer with the given ID.
521-
func (c *Client) WorkspaceAgentRecreateDevcontainer(ctx context.Context, agentID uuid.UUID, containerIDOrName string) error {
521+
func (c *Client) WorkspaceAgentRecreateDevcontainer(ctx context.Context, agentID uuid.UUID, containerIDOrName string) (Response, error) {
522522
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaceagents/%s/containers/devcontainers/container/%s/recreate", agentID, containerIDOrName), nil)
523523
if err != nil {
524-
return err
524+
return Response{}, err
525525
}
526526
defer res.Body.Close()
527-
if res.StatusCode != http.StatusNoContent {
528-
return ReadBodyAsError(res)
527+
if res.StatusCode != http.StatusAccepted {
528+
return Response{}, ReadBodyAsError(res)
529529
}
530-
return nil
530+
var m Response
531+
if err := json.NewDecoder(res.Body).Decode(&m); err != nil {
532+
return Response{}, xerrors.Errorf("decode response body: %w", err)
533+
}
534+
return m, nil
531535
}
532536

533537
//nolint:revive // Follow is a control flag on the server as well.

codersdk/workspacesdk/agentconn.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -389,18 +389,22 @@ func (c *AgentConn) ListContainers(ctx context.Context) (codersdk.WorkspaceAgent
389389

390390
// RecreateDevcontainer recreates a devcontainer with the given container.
391391
// This is a blocking call and will wait for the container to be recreated.
392-
func (c *AgentConn) RecreateDevcontainer(ctx context.Context, containerIDOrName string) error {
392+
func (c *AgentConn) RecreateDevcontainer(ctx context.Context, containerIDOrName string) (codersdk.Response, error) {
393393
ctx, span := tracing.StartSpan(ctx)
394394
defer span.End()
395395
res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/containers/devcontainers/container/"+containerIDOrName+"/recreate", nil)
396396
if err != nil {
397-
return xerrors.Errorf("do request: %w", err)
397+
return codersdk.Response{}, xerrors.Errorf("do request: %w", err)
398398
}
399399
defer res.Body.Close()
400400
if res.StatusCode != http.StatusAccepted {
401-
return codersdk.ReadBodyAsError(res)
401+
return codersdk.Response{}, codersdk.ReadBodyAsError(res)
402402
}
403-
return nil
403+
var m codersdk.Response
404+
if err := json.NewDecoder(res.Body).Decode(&m); err != nil {
405+
return codersdk.Response{}, xerrors.Errorf("decode response body: %w", err)
406+
}
407+
return m, nil
404408
}
405409

406410
// apiRequest makes a request to the workspace agent's HTTP API server.

site/src/modules/resources/AgentDevcontainerCard.tsx

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,23 @@ import {
99
TooltipProvider,
1010
TooltipTrigger,
1111
} from "components/Tooltip/Tooltip";
12-
import { ExternalLinkIcon } from "lucide-react";
12+
import {
13+
HelpTooltip,
14+
HelpTooltipContent,
15+
HelpTooltipText,
16+
HelpTooltipTitle,
17+
HelpTooltipTrigger,
18+
} from "components/HelpTooltip/HelpTooltip";
19+
import { ExternalLinkIcon, Loader2Icon } from "lucide-react";
1320
import type { FC } from "react";
1421
import { portForwardURL } from "utils/portForward";
1522
import { AgentButton } from "./AgentButton";
1623
import { AgentDevcontainerSSHButton } from "./SSHButton/SSHButton";
1724
import { TerminalLink } from "./TerminalLink/TerminalLink";
1825
import { VSCodeDevContainerButton } from "./VSCodeDevContainerButton/VSCodeDevContainerButton";
26+
import { useState } from "react";
27+
import { Button } from "components/Button/Button";
28+
import { displayError } from "components/GlobalSnackbar/utils";
1929

2030
type AgentDevcontainerCardProps = {
2131
agent: WorkspaceAgent;
@@ -32,24 +42,94 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
3242
}) => {
3343
const folderPath = container.labels["devcontainer.local_folder"];
3444
const containerFolder = container.volumes[folderPath];
45+
const [isRecreating, setIsRecreating] = useState(false);
46+
47+
const handleRecreateDevcontainer = async () => {
48+
setIsRecreating(true);
49+
let recreateSucceeded = false;
50+
try {
51+
const response = await fetch(
52+
`/api/v2/workspaceagents/${agent.id}/containers/devcontainers/container/${container.id}/recreate`,
53+
{
54+
method: "POST",
55+
},
56+
);
57+
if (!response.ok) {
58+
const errorData = await response.json().catch(() => ({}));
59+
throw new Error(
60+
errorData.message || `Failed to recreate: ${response.statusText}`,
61+
);
62+
}
63+
// If the request was accepted (e.g. 202), we mark it as succeeded.
64+
// Once complete, the component will unmount, so the spinner will
65+
// disappear with it.
66+
if (response.status === 202) {
67+
recreateSucceeded = true;
68+
}
69+
} catch (error) {
70+
const errorMessage =
71+
error instanceof Error ? error.message : "An unknown error occurred.";
72+
displayError(`Failed to recreate devcontainer: ${errorMessage}`);
73+
console.error("Failed to recreate devcontainer:", error);
74+
} finally {
75+
if (!recreateSucceeded) {
76+
setIsRecreating(false);
77+
}
78+
}
79+
};
3580

3681
return (
3782
<section
3883
className="border border-border border-dashed rounded p-6 "
3984
key={container.id}
4085
>
41-
<header className="flex justify-between">
42-
<h3 className="m-0 text-xs font-medium text-content-secondary">
43-
{container.name}
44-
</h3>
86+
<header className="flex justify-between items-center mb-4">
87+
<div className="flex items-center gap-2">
88+
<h3 className="m-0 text-xs font-medium text-content-secondary">
89+
{container.name}
90+
</h3>
91+
{container.devcontainer_dirty && (
92+
<HelpTooltip>
93+
<HelpTooltipTrigger className="flex items-center text-xs text-warning-foreground ml-2">
94+
<span>Outdated</span>
95+
</HelpTooltipTrigger>
96+
<HelpTooltipContent>
97+
<HelpTooltipTitle>Devcontainer Outdated</HelpTooltipTitle>
98+
<HelpTooltipText>
99+
Devcontainer configuration has been modified and is outdated.
100+
Recreate to get an up-to-date container.
101+
</HelpTooltipText>
102+
</HelpTooltipContent>
103+
</HelpTooltip>
104+
)}
105+
</div>
45106

46-
<AgentDevcontainerSSHButton
47-
workspace={workspace.name}
48-
container={container.name}
49-
/>
107+
<div className="flex items-center gap-2">
108+
<Button
109+
variant="outline"
110+
size="sm"
111+
className="text-xs font-medium"
112+
onClick={handleRecreateDevcontainer}
113+
disabled={isRecreating}
114+
>
115+
{isRecreating ? (
116+
<>
117+
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
118+
Recreating...
119+
</>
120+
) : (
121+
"Recreate"
122+
)}
123+
</Button>
124+
125+
<AgentDevcontainerSSHButton
126+
workspace={workspace.name}
127+
container={container.name}
128+
/>
129+
</div>
50130
</header>
51131

52-
<h4 className="m-0 text-xl font-semibold">Forwarded ports</h4>
132+
<h4 className="m-0 text-xl font-semibold mb-2">Forwarded ports</h4>
53133

54134
<div className="flex gap-4 flex-wrap mt-4">
55135
<VSCodeDevContainerButton

0 commit comments

Comments
 (0)