mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
+1
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+23
@@ -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": {
|
||||
|
||||
Generated
+18
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+1
@@ -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": {
|
||||
|
||||
Generated
+34
-14
@@ -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": {
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user