feat(site): allow recreating devcontainers and showing dirty status (#18049)

This change allows showing the devcontainer dirty status in the UI as
well as a recreate button to update the devcontainer.

Closes #16424
This commit is contained in:
Mathias Fredriksson
2025-05-27 19:42:24 +03:00
committed by GitHub
parent 23d14233bf
commit a18eb9d08f
14 changed files with 275 additions and 46 deletions
+1 -1
View File
@@ -2226,7 +2226,7 @@ func TestAgent_DevcontainerRecreate(t *testing.T) {
// devcontainer, we do it in a goroutine so we can process logs
// concurrently.
go func(container codersdk.WorkspaceAgentContainer) {
err := conn.RecreateDevcontainer(ctx, container.ID)
_, err := conn.RecreateDevcontainer(ctx, container.ID)
assert.NoError(t, err, "recreate devcontainer should succeed")
}(container)
+22 -4
View File
@@ -403,6 +403,7 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code
// Check if the container is running and update the known devcontainers.
for i := range updated.Containers {
container := &updated.Containers[i] // Grab a reference to the container to allow mutating it.
container.DevcontainerStatus = "" // Reset the status for the container (updated later).
container.DevcontainerDirty = false // Reset dirty state for the container (updated later).
workspaceFolder := container.Labels[DevcontainerLocalFolderLabel]
@@ -465,9 +466,17 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code
for _, dc := range api.knownDevcontainers {
switch {
case dc.Status == codersdk.WorkspaceAgentDevcontainerStatusStarting:
if dc.Container != nil {
dc.Container.DevcontainerStatus = dc.Status
dc.Container.DevcontainerDirty = dc.Dirty
}
continue // This state is handled by the recreation routine.
case dc.Status == codersdk.WorkspaceAgentDevcontainerStatusError && (dc.Container == nil || dc.Container.CreatedAt.Before(api.recreateErrorTimes[dc.WorkspaceFolder])):
if dc.Container != nil {
dc.Container.DevcontainerStatus = dc.Status
dc.Container.DevcontainerDirty = dc.Dirty
}
continue // The devcontainer needs to be recreated.
case dc.Container != nil:
@@ -475,6 +484,7 @@ func (api *API) processUpdatedContainersLocked(ctx context.Context, updated code
if dc.Container.Running {
dc.Status = codersdk.WorkspaceAgentDevcontainerStatusRunning
}
dc.Container.DevcontainerStatus = dc.Status
dc.Dirty = false
if lastModified, hasModTime := api.configFileModifiedTimes[dc.ConfigPath]; hasModTime && dc.Container.CreatedAt.Before(lastModified) {
@@ -608,6 +618,9 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques
// Update the status so that we don't try to recreate the
// devcontainer multiple times in parallel.
dc.Status = codersdk.WorkspaceAgentDevcontainerStatusStarting
if dc.Container != nil {
dc.Container.DevcontainerStatus = dc.Status
}
api.knownDevcontainers[dc.WorkspaceFolder] = dc
api.recreateWg.Add(1)
go api.recreateDevcontainer(dc, configPath)
@@ -680,6 +693,9 @@ func (api *API) recreateDevcontainer(dc codersdk.WorkspaceAgentDevcontainer, con
api.mu.Lock()
dc = api.knownDevcontainers[dc.WorkspaceFolder]
dc.Status = codersdk.WorkspaceAgentDevcontainerStatusError
if dc.Container != nil {
dc.Container.DevcontainerStatus = dc.Status
}
api.knownDevcontainers[dc.WorkspaceFolder] = dc
api.recreateErrorTimes[dc.WorkspaceFolder] = api.clock.Now("recreate", "errorTimes")
api.mu.Unlock()
@@ -695,10 +711,12 @@ func (api *API) recreateDevcontainer(dc codersdk.WorkspaceAgentDevcontainer, con
// allows the update routine to update the devcontainer status, but
// to minimize the time between API consistency, we guess the status
// based on the container state.
if dc.Container != nil && dc.Container.Running {
dc.Status = codersdk.WorkspaceAgentDevcontainerStatusRunning
} else {
dc.Status = codersdk.WorkspaceAgentDevcontainerStatusStopped
dc.Status = codersdk.WorkspaceAgentDevcontainerStatusStopped
if dc.Container != nil {
if dc.Container.Running {
dc.Status = codersdk.WorkspaceAgentDevcontainerStatusRunning
}
dc.Container.DevcontainerStatus = dc.Status
}
dc.Dirty = false
api.recreateSuccessTimes[dc.WorkspaceFolder] = api.clock.Now("recreate", "successTimes")
+31 -5
View File
@@ -477,6 +477,8 @@ func TestAPI(t *testing.T) {
require.NoError(t, err, "unmarshal response failed")
require.Len(t, resp.Devcontainers, 1, "expected one devcontainer in response")
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusStarting, resp.Devcontainers[0].Status, "devcontainer is not starting")
require.NotNil(t, resp.Devcontainers[0].Container, "devcontainer should have container reference")
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusStarting, resp.Devcontainers[0].Container.DevcontainerStatus, "container dc status is not starting")
// Allow the devcontainer CLI to continue the up process.
close(tt.devcontainerCLI.continueUp)
@@ -503,6 +505,8 @@ func TestAPI(t *testing.T) {
require.NoError(t, err, "unmarshal response failed after error")
require.Len(t, resp.Devcontainers, 1, "expected one devcontainer in response after error")
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusError, resp.Devcontainers[0].Status, "devcontainer is not in an error state after up failure")
require.NotNil(t, resp.Devcontainers[0].Container, "devcontainer should have container reference after up failure")
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusError, resp.Devcontainers[0].Container.DevcontainerStatus, "container dc status is not error after up failure")
return
}
@@ -525,7 +529,9 @@ func TestAPI(t *testing.T) {
err = json.NewDecoder(rec.Body).Decode(&resp)
require.NoError(t, err, "unmarshal response failed after recreation")
require.Len(t, resp.Devcontainers, 1, "expected one devcontainer in response after recreation")
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, resp.Devcontainers[0].Status, "devcontainer is not stopped after recreation")
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, resp.Devcontainers[0].Status, "devcontainer is not running after recreation")
require.NotNil(t, resp.Devcontainers[0].Container, "devcontainer should have container reference after recreation")
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, resp.Devcontainers[0].Container.DevcontainerStatus, "container dc status is not running after recreation")
})
}
})
@@ -620,6 +626,7 @@ func TestAPI(t *testing.T) {
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, dc.Status)
require.NotNil(t, dc.Container)
assert.Equal(t, "runtime-container-1", dc.Container.ID)
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, dc.Container.DevcontainerStatus)
},
},
{
@@ -660,12 +667,14 @@ func TestAPI(t *testing.T) {
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusStopped, known2.Status)
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, runtime1.Status)
require.NotNil(t, known1.Container)
assert.Nil(t, known2.Container)
require.NotNil(t, runtime1.Container)
require.NotNil(t, known1.Container)
assert.Equal(t, "known-container-1", known1.Container.ID)
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, known1.Container.DevcontainerStatus)
require.NotNil(t, runtime1.Container)
assert.Equal(t, "runtime-container-1", runtime1.Container.ID)
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, runtime1.Container.DevcontainerStatus)
},
},
{
@@ -704,10 +713,12 @@ func TestAPI(t *testing.T) {
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusStopped, nonRunning.Status)
require.NotNil(t, running.Container, "running container should have container reference")
require.NotNil(t, nonRunning.Container, "non-running container should have container reference")
assert.Equal(t, "running-container", running.Container.ID)
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, running.Container.DevcontainerStatus)
require.NotNil(t, nonRunning.Container, "non-running container should have container reference")
assert.Equal(t, "non-running-container", nonRunning.Container.ID)
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusStopped, nonRunning.Container.DevcontainerStatus)
},
},
{
@@ -743,6 +754,7 @@ func TestAPI(t *testing.T) {
assert.NotEmpty(t, dc2.ConfigPath)
require.NotNil(t, dc2.Container)
assert.Equal(t, "known-container-2", dc2.Container.ID)
assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, dc2.Container.DevcontainerStatus)
},
},
{
@@ -811,9 +823,14 @@ func TestAPI(t *testing.T) {
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
mClock := quartz.NewMock(t)
mClock.Set(time.Now()).MustWait(testutil.Context(t, testutil.WaitShort))
tickerTrap := mClock.Trap().TickerFunc("updaterLoop")
// Setup router with the handler under test.
r := chi.NewRouter()
apiOptions := []agentcontainers.Option{
agentcontainers.WithClock(mClock),
agentcontainers.WithLister(tt.lister),
agentcontainers.WithWatcher(watcher.NewNoop()),
}
@@ -838,6 +855,15 @@ func TestAPI(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitShort)
// Make sure the ticker function has been registered
// before advancing the clock.
tickerTrap.MustWait(ctx).MustRelease(ctx)
tickerTrap.Close()
// Advance the clock to run the updater loop.
_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)
req := httptest.NewRequest(http.MethodGet, "/devcontainers", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
+23
View File
@@ -17201,6 +17201,14 @@ const docTemplate = `{
"description": "DevcontainerDirty is true if the devcontainer configuration has changed\nsince the container was created. This is used to determine if the\ncontainer needs to be rebuilt.",
"type": "boolean"
},
"devcontainer_status": {
"description": "DevcontainerStatus is the status of the devcontainer, if this\ncontainer is a devcontainer. This is used to determine if the\ndevcontainer is running, stopped, starting, or in an error state.",
"allOf": [
{
"$ref": "#/definitions/codersdk.WorkspaceAgentDevcontainerStatus"
}
]
},
"id": {
"description": "ID is the unique identifier of the container.",
"type": "string"
@@ -17265,6 +17273,21 @@ const docTemplate = `{
}
}
},
"codersdk.WorkspaceAgentDevcontainerStatus": {
"type": "string",
"enum": [
"running",
"stopped",
"starting",
"error"
],
"x-enum-varnames": [
"WorkspaceAgentDevcontainerStatusRunning",
"WorkspaceAgentDevcontainerStatusStopped",
"WorkspaceAgentDevcontainerStatusStarting",
"WorkspaceAgentDevcontainerStatusError"
]
},
"codersdk.WorkspaceAgentHealth": {
"type": "object",
"properties": {
+18
View File
@@ -15706,6 +15706,14 @@
"description": "DevcontainerDirty is true if the devcontainer configuration has changed\nsince the container was created. This is used to determine if the\ncontainer needs to be rebuilt.",
"type": "boolean"
},
"devcontainer_status": {
"description": "DevcontainerStatus is the status of the devcontainer, if this\ncontainer is a devcontainer. This is used to determine if the\ndevcontainer is running, stopped, starting, or in an error state.",
"allOf": [
{
"$ref": "#/definitions/codersdk.WorkspaceAgentDevcontainerStatus"
}
]
},
"id": {
"description": "ID is the unique identifier of the container.",
"type": "string"
@@ -15770,6 +15778,16 @@
}
}
},
"codersdk.WorkspaceAgentDevcontainerStatus": {
"type": "string",
"enum": ["running", "stopped", "starting", "error"],
"x-enum-varnames": [
"WorkspaceAgentDevcontainerStatusRunning",
"WorkspaceAgentDevcontainerStatusStopped",
"WorkspaceAgentDevcontainerStatusStarting",
"WorkspaceAgentDevcontainerStatusError"
]
},
"codersdk.WorkspaceAgentHealth": {
"type": "object",
"properties": {
+2 -2
View File
@@ -956,7 +956,7 @@ func (api *API) workspaceAgentRecreateDevcontainer(rw http.ResponseWriter, r *ht
}
defer release()
err = agentConn.RecreateDevcontainer(ctx, container)
m, err := agentConn.RecreateDevcontainer(ctx, container)
if err != nil {
if errors.Is(err, context.Canceled) {
httpapi.Write(ctx, rw, http.StatusRequestTimeout, codersdk.Response{
@@ -977,7 +977,7 @@ func (api *API) workspaceAgentRecreateDevcontainer(rw http.ResponseWriter, r *ht
return
}
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
httpapi.Write(ctx, rw, http.StatusAccepted, m)
}
// @Summary Get connection info for workspace agent
+1 -1
View File
@@ -1483,7 +1483,7 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) {
ctx := testutil.Context(t, testutil.WaitLong)
err := client.WorkspaceAgentRecreateDevcontainer(ctx, agentID, devContainer.ID)
_, err := client.WorkspaceAgentRecreateDevcontainer(ctx, agentID, devContainer.ID)
if wantStatus > 0 {
cerr, ok := codersdk.AsError(err)
require.True(t, ok, "expected error to be a coder error")
+13 -5
View File
@@ -450,6 +450,10 @@ type WorkspaceAgentContainer struct {
// Volumes is a map of "things" mounted into the container. Again, this
// is somewhat implementation-dependent.
Volumes map[string]string `json:"volumes"`
// DevcontainerStatus is the status of the devcontainer, if this
// container is a devcontainer. This is used to determine if the
// devcontainer is running, stopped, starting, or in an error state.
DevcontainerStatus WorkspaceAgentDevcontainerStatus `json:"devcontainer_status,omitempty"`
// DevcontainerDirty is true if the devcontainer configuration has changed
// since the container was created. This is used to determine if the
// container needs to be rebuilt.
@@ -518,16 +522,20 @@ func (c *Client) WorkspaceAgentListContainers(ctx context.Context, agentID uuid.
}
// WorkspaceAgentRecreateDevcontainer recreates the devcontainer with the given ID.
func (c *Client) WorkspaceAgentRecreateDevcontainer(ctx context.Context, agentID uuid.UUID, containerIDOrName string) error {
func (c *Client) WorkspaceAgentRecreateDevcontainer(ctx context.Context, agentID uuid.UUID, containerIDOrName string) (Response, error) {
res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaceagents/%s/containers/devcontainers/container/%s/recreate", agentID, containerIDOrName), nil)
if err != nil {
return err
return Response{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusNoContent {
return ReadBodyAsError(res)
if res.StatusCode != http.StatusAccepted {
return Response{}, ReadBodyAsError(res)
}
return nil
var m Response
if err := json.NewDecoder(res.Body).Decode(&m); err != nil {
return Response{}, xerrors.Errorf("decode response body: %w", err)
}
return m, nil
}
//nolint:revive // Follow is a control flag on the server as well.
+8 -4
View File
@@ -389,18 +389,22 @@ func (c *AgentConn) ListContainers(ctx context.Context) (codersdk.WorkspaceAgent
// RecreateDevcontainer recreates a devcontainer with the given container.
// This is a blocking call and will wait for the container to be recreated.
func (c *AgentConn) RecreateDevcontainer(ctx context.Context, containerIDOrName string) error {
func (c *AgentConn) RecreateDevcontainer(ctx context.Context, containerIDOrName string) (codersdk.Response, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
res, err := c.apiRequest(ctx, http.MethodPost, "/api/v0/containers/devcontainers/container/"+containerIDOrName+"/recreate", nil)
if err != nil {
return xerrors.Errorf("do request: %w", err)
return codersdk.Response{}, xerrors.Errorf("do request: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusAccepted {
return codersdk.ReadBodyAsError(res)
return codersdk.Response{}, codersdk.ReadBodyAsError(res)
}
return nil
var m codersdk.Response
if err := json.NewDecoder(res.Body).Decode(&m); err != nil {
return codersdk.Response{}, xerrors.Errorf("decode response body: %w", err)
}
return m, nil
}
// apiRequest makes a request to the workspace agent's HTTP API server.
+1
View File
@@ -777,6 +777,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con
{
"created_at": "2019-08-24T14:15:22Z",
"devcontainer_dirty": true,
"devcontainer_status": "running",
"id": "string",
"image": "string",
"labels": {
+34 -14
View File
@@ -8638,6 +8638,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
{
"created_at": "2019-08-24T14:15:22Z",
"devcontainer_dirty": true,
"devcontainer_status": "running",
"id": "string",
"image": "string",
"labels": {
@@ -8664,20 +8665,21 @@ If the schedule is empty, the user will be updated to use the default schedule.|
### Properties
| Name | Type | Required | Restrictions | Description |
|----------------------|---------------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `created_at` | string | false | | Created at is the time the container was created. |
| `devcontainer_dirty` | boolean | false | | Devcontainer dirty is true if the devcontainer configuration has changed since the container was created. This is used to determine if the container needs to be rebuilt. |
| `id` | string | false | | ID is the unique identifier of the container. |
| `image` | string | false | | Image is the name of the container image. |
| `labels` | object | false | | Labels is a map of key-value pairs of container labels. |
| » `[any property]` | string | false | | |
| `name` | string | false | | Name is the human-readable name of the container. |
| `ports` | array of [codersdk.WorkspaceAgentContainerPort](#codersdkworkspaceagentcontainerport) | false | | Ports includes ports exposed by the container. |
| `running` | boolean | false | | Running is true if the container is currently running. |
| `status` | string | false | | Status is the current status of the container. This is somewhat implementation-dependent, but should generally be a human-readable string. |
| `volumes` | object | false | | Volumes is a map of "things" mounted into the container. Again, this is somewhat implementation-dependent. |
| » `[any property]` | string | false | | |
| Name | Type | Required | Restrictions | Description |
|-----------------------|----------------------------------------------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `created_at` | string | false | | Created at is the time the container was created. |
| `devcontainer_dirty` | boolean | false | | Devcontainer dirty is true if the devcontainer configuration has changed since the container was created. This is used to determine if the container needs to be rebuilt. |
| `devcontainer_status` | [codersdk.WorkspaceAgentDevcontainerStatus](#codersdkworkspaceagentdevcontainerstatus) | false | | Devcontainer status is the status of the devcontainer, if this container is a devcontainer. This is used to determine if the devcontainer is running, stopped, starting, or in an error state. |
| `id` | string | false | | ID is the unique identifier of the container. |
| `image` | string | false | | Image is the name of the container image. |
| `labels` | object | false | | Labels is a map of key-value pairs of container labels. |
| » `[any property]` | string | false | | |
| `name` | string | false | | Name is the human-readable name of the container. |
| `ports` | array of [codersdk.WorkspaceAgentContainerPort](#codersdkworkspaceagentcontainerport) | false | | Ports includes ports exposed by the container. |
| `running` | boolean | false | | Running is true if the container is currently running. |
| `status` | string | false | | Status is the current status of the container. This is somewhat implementation-dependent, but should generally be a human-readable string. |
| `volumes` | object | false | | Volumes is a map of "things" mounted into the container. Again, this is somewhat implementation-dependent. |
| » `[any property]` | string | false | | |
## codersdk.WorkspaceAgentContainerPort
@@ -8699,6 +8701,23 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `network` | string | false | | Network is the network protocol used by the port (tcp, udp, etc). |
| `port` | integer | false | | Port is the port number *inside* the container. |
## codersdk.WorkspaceAgentDevcontainerStatus
```json
"running"
```
### Properties
#### Enumerated Values
| Value |
|------------|
| `running` |
| `stopped` |
| `starting` |
| `error` |
## codersdk.WorkspaceAgentHealth
```json
@@ -8745,6 +8764,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
{
"created_at": "2019-08-24T14:15:22Z",
"devcontainer_dirty": true,
"devcontainer_status": "running",
"id": "string",
"image": "string",
"labels": {
+1
View File
@@ -3352,6 +3352,7 @@ export interface WorkspaceAgentContainer {
readonly ports: readonly WorkspaceAgentContainerPort[];
readonly status: string;
readonly volumes: Record<string, string>;
readonly devcontainer_status?: WorkspaceAgentDevcontainerStatus;
readonly devcontainer_dirty: boolean;
}
@@ -31,3 +31,24 @@ export const WithPorts: Story = {
},
},
};
export const Dirty: Story = {
args: {
container: {
...MockWorkspaceAgentContainer,
devcontainer_dirty: true,
ports: MockWorkspaceAgentContainerPorts,
},
},
};
export const Recreating: Story = {
args: {
container: {
...MockWorkspaceAgentContainer,
devcontainer_dirty: true,
devcontainer_status: "starting",
ports: MockWorkspaceAgentContainerPorts,
},
},
};
@@ -3,14 +3,24 @@ import type {
WorkspaceAgent,
WorkspaceAgentContainer,
} from "api/typesGenerated";
import { Button } from "components/Button/Button";
import { displayError } from "components/GlobalSnackbar/utils";
import {
HelpTooltip,
HelpTooltipContent,
HelpTooltipText,
HelpTooltipTitle,
HelpTooltipTrigger,
} from "components/HelpTooltip/HelpTooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { ExternalLinkIcon } from "lucide-react";
import { ExternalLinkIcon, Loader2Icon } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { portForwardURL } from "utils/portForward";
import { AgentButton } from "./AgentButton";
import { AgentDevcontainerSSHButton } from "./SSHButton/SSHButton";
@@ -32,24 +42,103 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
}) => {
const folderPath = container.labels["devcontainer.local_folder"];
const containerFolder = container.volumes[folderPath];
const [isRecreating, setIsRecreating] = useState(false);
const handleRecreateDevcontainer = async () => {
setIsRecreating(true);
let recreateSucceeded = false;
try {
const response = await fetch(
`/api/v2/workspaceagents/${agent.id}/containers/devcontainers/container/${container.id}/recreate`,
{
method: "POST",
},
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `Failed to recreate: ${response.statusText}`,
);
}
// If the request was accepted (e.g. 202), we mark it as succeeded.
// Once complete, the component will unmount, so the spinner will
// disappear with it.
if (response.status === 202) {
recreateSucceeded = true;
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred.";
displayError(`Failed to recreate devcontainer: ${errorMessage}`);
console.error("Failed to recreate devcontainer:", error);
} finally {
if (!recreateSucceeded) {
setIsRecreating(false);
}
}
};
// If the container is starting, reflect this in the recreate button.
useEffect(() => {
if (container.devcontainer_status === "starting") {
setIsRecreating(true);
} else {
setIsRecreating(false);
}
}, [container.devcontainer_status]);
return (
<section
className="border border-border border-dashed rounded p-6 "
key={container.id}
>
<header className="flex justify-between">
<h3 className="m-0 text-xs font-medium text-content-secondary">
{container.name}
</h3>
<header className="flex justify-between items-center mb-4">
<div className="flex items-center gap-2">
<h3 className="m-0 text-xs font-medium text-content-secondary">
{container.name}
</h3>
{container.devcontainer_dirty && (
<HelpTooltip>
<HelpTooltipTrigger className="flex items-center text-xs text-content-warning ml-2">
<span>Outdated</span>
</HelpTooltipTrigger>
<HelpTooltipContent>
<HelpTooltipTitle>Devcontainer Outdated</HelpTooltipTitle>
<HelpTooltipText>
Devcontainer configuration has been modified and is outdated.
Recreate to get an up-to-date container.
</HelpTooltipText>
</HelpTooltipContent>
</HelpTooltip>
)}
</div>
<AgentDevcontainerSSHButton
workspace={workspace.name}
container={container.name}
/>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="text-xs font-medium"
onClick={handleRecreateDevcontainer}
disabled={isRecreating}
>
{isRecreating ? (
<>
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
Recreating...
</>
) : (
"Recreate"
)}
</Button>
<AgentDevcontainerSSHButton
workspace={workspace.name}
container={container.name}
/>
</div>
</header>
<h4 className="m-0 text-xl font-semibold">Forwarded ports</h4>
<h4 className="m-0 text-xl font-semibold mb-2">Forwarded ports</h4>
<div className="flex gap-4 flex-wrap mt-4">
<VSCodeDevContainerButton