fix(docker): show task titles in container lists
This commit is contained in:
@@ -626,7 +626,7 @@ func registerSecondaryRoutes(
|
||||
p.log.Debug("Registered Linear handlers (HTTP + WebSocket)")
|
||||
}
|
||||
|
||||
docker.RegisterDockerRoutes(p.router, p.lifecycleMgr.DockerClientProvider(), p.log)
|
||||
docker.RegisterDockerRoutes(p.router, p.lifecycleMgr.DockerClientProvider(), dockerTaskTitleProvider(p.taskRepo, p.log), p.log)
|
||||
p.log.Debug("Registered Docker management handlers (HTTP)")
|
||||
|
||||
registerHealthRoutes(p)
|
||||
@@ -645,6 +645,21 @@ func registerSecondaryRoutes(
|
||||
registerE2EResetRoutes(p.router, p.taskRepo, p.log)
|
||||
}
|
||||
|
||||
func dockerTaskTitleProvider(taskRepo *sqliterepo.Repository, log *logger.Logger) docker.TaskTitleProvider {
|
||||
return func(ctx context.Context, taskID string) (string, bool) {
|
||||
if taskRepo == nil || taskID == "" {
|
||||
return "", false
|
||||
}
|
||||
task, err := taskRepo.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
log.Debug("docker container task title lookup failed",
|
||||
zap.String("task_id", taskID), zap.Error(err))
|
||||
return "", false
|
||||
}
|
||||
return task.Title, task.Title != ""
|
||||
}
|
||||
}
|
||||
|
||||
// registerHealthRoutes sets up the system health endpoint with all health checkers.
|
||||
func registerHealthRoutes(p routeParams) {
|
||||
var githubProvider health.GitHubStatusProvider
|
||||
|
||||
@@ -3,6 +3,7 @@ package docker
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -37,12 +38,15 @@ type stopContainerRequest struct {
|
||||
// ClientProvider lazily resolves the Docker client. Returns nil if Docker is unavailable.
|
||||
type ClientProvider func() *Client
|
||||
|
||||
// TaskTitleProvider resolves a task ID to its display title for container listings.
|
||||
type TaskTitleProvider func(ctx context.Context, taskID string) (string, bool)
|
||||
|
||||
// RegisterDockerRoutes registers Docker management HTTP routes on the given router.
|
||||
// The clientProvider lazily resolves the Docker client on each request.
|
||||
func RegisterDockerRoutes(router *gin.Engine, clientProvider ClientProvider, log *logger.Logger) {
|
||||
func RegisterDockerRoutes(router *gin.Engine, clientProvider ClientProvider, taskTitleProvider TaskTitleProvider, log *logger.Logger) {
|
||||
api := router.Group("/api/v1/docker")
|
||||
api.POST("/build", handleBuildImage(clientProvider, log))
|
||||
api.GET("/containers", handleListContainers(clientProvider, log))
|
||||
api.GET("/containers", handleListContainers(clientProvider, taskTitleProvider, log))
|
||||
api.POST("/containers/:id/stop", handleStopContainer(clientProvider, log))
|
||||
api.DELETE("/containers/:id", handleRemoveContainer(clientProvider, log))
|
||||
}
|
||||
@@ -114,7 +118,7 @@ func streamBuildOutput(c *gin.Context, reader interface{ Read([]byte) (int, erro
|
||||
|
||||
// handleListContainers handles GET /api/v1/docker/containers.
|
||||
// Supports optional query params: image, labels (comma-separated key=value pairs).
|
||||
func handleListContainers(clientProvider ClientProvider, log *logger.Logger) gin.HandlerFunc {
|
||||
func handleListContainers(clientProvider ClientProvider, taskTitleProvider TaskTitleProvider, log *logger.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
dockerClient := requireDocker(c, clientProvider)
|
||||
if dockerClient == nil {
|
||||
@@ -131,13 +135,17 @@ func handleListContainers(clientProvider ClientProvider, log *logger.Logger) gin
|
||||
return
|
||||
}
|
||||
|
||||
resp := newContainerResponses(containers)
|
||||
resp := newContainerResponsesWithTaskTitles(c.Request.Context(), containers, taskTitleProvider)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"containers": resp})
|
||||
}
|
||||
}
|
||||
|
||||
func newContainerResponses(containers []ContainerInfo) []containerResponse {
|
||||
return newContainerResponsesWithTaskTitles(context.Background(), containers, nil)
|
||||
}
|
||||
|
||||
func newContainerResponsesWithTaskTitles(ctx context.Context, containers []ContainerInfo, taskTitleProvider TaskTitleProvider) []containerResponse {
|
||||
resp := make([]containerResponse, len(containers))
|
||||
for i, ctr := range containers {
|
||||
resp[i] = containerResponse{
|
||||
@@ -147,12 +155,30 @@ func newContainerResponses(containers []ContainerInfo) []containerResponse {
|
||||
State: ctr.State,
|
||||
Status: ctr.Status,
|
||||
StartedAt: ctr.StartedAt,
|
||||
Labels: ctr.Labels,
|
||||
Labels: labelsWithTaskTitle(ctx, ctr.Labels, taskTitleProvider),
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func labelsWithTaskTitle(ctx context.Context, labels map[string]string, taskTitleProvider TaskTitleProvider) map[string]string {
|
||||
if len(labels) == 0 {
|
||||
return labels
|
||||
}
|
||||
result := make(map[string]string, len(labels)+1)
|
||||
for key, value := range labels {
|
||||
result[key] = value
|
||||
}
|
||||
if result["kandev.task_title"] != "" || taskTitleProvider == nil {
|
||||
return result
|
||||
}
|
||||
title, ok := taskTitleProvider(ctx, result["kandev.task_id"])
|
||||
if ok && title != "" {
|
||||
result["kandev.task_title"] = title
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseLabelsQuery extracts label filters from the "labels" query parameter.
|
||||
// Expected format: "key1=value1,key2=value2".
|
||||
func parseLabelsQuery(c *gin.Context) map[string]string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -28,3 +29,27 @@ func TestContainerResponsesIncludeLabels(t *testing.T) {
|
||||
t.Fatalf("labels = %#v, want task label", got[0].Labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerResponsesEnrichTaskTitleLabel(t *testing.T) {
|
||||
containers := []ContainerInfo{{
|
||||
ID: "container-1",
|
||||
Name: "kandev-agent-1",
|
||||
Image: "kandev/agent:test",
|
||||
State: "running",
|
||||
Status: "Up 2 seconds",
|
||||
Labels: map[string]string{
|
||||
"kandev.task_id": "task-1",
|
||||
},
|
||||
}}
|
||||
|
||||
got := newContainerResponsesWithTaskTitles(context.Background(), containers, func(_ context.Context, taskID string) (string, bool) {
|
||||
return "Readable Task Title", taskID == "task-1"
|
||||
})
|
||||
|
||||
if got[0].Labels["kandev.task_title"] != "Readable Task Title" {
|
||||
t.Fatalf("labels = %#v, want task title label", got[0].Labels)
|
||||
}
|
||||
if _, exists := containers[0].Labels["kandev.task_title"]; exists {
|
||||
t.Fatalf("source labels mutated: %#v", containers[0].Labels)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ type ContainerConfig struct {
|
||||
AgentConfig agents.Agent
|
||||
WorkspacePath string // If empty, workspace is not mounted (will clone inside container)
|
||||
TaskID string
|
||||
TaskTitle string
|
||||
TaskEnvironmentID string
|
||||
TaskDescription string
|
||||
Model string
|
||||
@@ -447,6 +448,9 @@ exec /usr/local/bin/agentctl`,
|
||||
containerCfg.Labels["kandev.executor_profile_id"] = config.ExecutorProfileID
|
||||
containerCfg.Labels["kandev.profile_id"] = config.ExecutorProfileID
|
||||
}
|
||||
if config.TaskTitle != "" {
|
||||
containerCfg.Labels["kandev.task_title"] = config.TaskTitle
|
||||
}
|
||||
if config.ProfileInfo != nil && config.ProfileInfo.ProfileID != "" {
|
||||
containerCfg.Labels["kandev.profile_id"] = config.ProfileInfo.ProfileID
|
||||
}
|
||||
|
||||
@@ -134,6 +134,7 @@ func TestBuildContainerConfig_LabelsExecutorProfileAndTaskEnvironment(t *testing
|
||||
AgentConfig: newConfigStubAgent(),
|
||||
InstanceID: "0123456789abcdef",
|
||||
TaskID: "task-1",
|
||||
TaskTitle: "Readable Task Title",
|
||||
SessionID: "session-1",
|
||||
TaskEnvironmentID: "env-1",
|
||||
ExecutorProfileID: "profile-1",
|
||||
@@ -147,6 +148,7 @@ func TestBuildContainerConfig_LabelsExecutorProfileAndTaskEnvironment(t *testing
|
||||
|
||||
assertLabel(t, got.Labels, "kandev.managed", boolStringTrue)
|
||||
assertLabel(t, got.Labels, "kandev.task_id", "task-1")
|
||||
assertLabel(t, got.Labels, "kandev.task_title", "Readable Task Title")
|
||||
assertLabel(t, got.Labels, "kandev.session_id", "session-1")
|
||||
assertLabel(t, got.Labels, "kandev.task_environment_id", "env-1")
|
||||
assertLabel(t, got.Labels, "kandev.executor_profile_id", "profile-1")
|
||||
|
||||
@@ -173,6 +173,7 @@ type RemoteStatusProvider interface {
|
||||
type ExecutorCreateRequest struct {
|
||||
InstanceID string
|
||||
TaskID string
|
||||
TaskTitle string
|
||||
SessionID string
|
||||
TaskEnvironmentID string // Env this execution belongs to (shared across sessions in same task)
|
||||
AgentProfileID string
|
||||
|
||||
@@ -165,6 +165,7 @@ func (r *DockerExecutor) CreateInstance(ctx context.Context, req *ExecutorCreate
|
||||
AgentConfig: req.AgentConfig,
|
||||
WorkspacePath: "", // Empty = no workspace mount, we'll clone instead
|
||||
TaskID: req.TaskID,
|
||||
TaskTitle: req.TaskTitle,
|
||||
TaskEnvironmentID: req.TaskEnvironmentID,
|
||||
SessionID: req.SessionID,
|
||||
ExecutorProfileID: getMetadataString(req.Metadata, "executor_profile_id"),
|
||||
|
||||
@@ -365,6 +365,7 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st
|
||||
execReq := &ExecutorCreateRequest{
|
||||
InstanceID: executionID,
|
||||
TaskID: reqWithWorktree.TaskID,
|
||||
TaskTitle: reqWithWorktree.TaskTitle,
|
||||
SessionID: reqWithWorktree.SessionID,
|
||||
TaskEnvironmentID: reqWithWorktree.TaskEnvironmentID,
|
||||
AgentProfileID: reqWithWorktree.AgentProfileID,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { containerTaskLabel } from "./docker-sections";
|
||||
import type { DockerContainer } from "@/lib/api/domains/settings-api";
|
||||
|
||||
const taskID = "task-abc123";
|
||||
|
||||
function container(labels?: Record<string, string>): DockerContainer {
|
||||
return {
|
||||
id: "container-1",
|
||||
name: "kandev-agent-test",
|
||||
image: "kandev/multi-agent:latest",
|
||||
state: "running",
|
||||
status: "Up 5 seconds",
|
||||
labels,
|
||||
};
|
||||
}
|
||||
|
||||
describe("containerTaskLabel", () => {
|
||||
it("prefers task title over task id", () => {
|
||||
expect(
|
||||
containerTaskLabel(
|
||||
container({
|
||||
"kandev.task_id": taskID,
|
||||
"kandev.task_title": "Fix Docker reuse",
|
||||
}),
|
||||
),
|
||||
).toBe("Fix Docker reuse");
|
||||
});
|
||||
|
||||
it("falls back to task id for older containers", () => {
|
||||
expect(containerTaskLabel(container({ "kandev.task_id": taskID }))).toBe(taskID);
|
||||
});
|
||||
|
||||
it("falls back to task id when the task title label is empty", () => {
|
||||
expect(
|
||||
containerTaskLabel(
|
||||
container({
|
||||
"kandev.task_id": taskID,
|
||||
"kandev.task_title": " ",
|
||||
}),
|
||||
),
|
||||
).toBe(taskID);
|
||||
});
|
||||
});
|
||||
@@ -293,8 +293,9 @@ export function useDockerProfileContainers(profileId: string, enabled = true) {
|
||||
return { containers, loading, refresh };
|
||||
}
|
||||
|
||||
function containerTaskLabel(container: DockerContainer) {
|
||||
return container.labels?.["kandev.task_id"] ?? "Untracked";
|
||||
export function containerTaskLabel(container: DockerContainer) {
|
||||
const title = container.labels?.["kandev.task_title"]?.trim();
|
||||
return title || container.labels?.["kandev.task_id"] || "Untracked";
|
||||
}
|
||||
|
||||
function ContainerRow({
|
||||
@@ -313,7 +314,7 @@ function ContainerRow({
|
||||
<TableRow data-testid={`docker-container-row-${container.id}`}>
|
||||
<TableCell className="font-mono text-sm">{container.name}</TableCell>
|
||||
<TableCell className="text-sm">{container.image}</TableCell>
|
||||
<TableCell className="font-mono text-xs" data-testid="docker-container-task">
|
||||
<TableCell className="text-sm" data-testid="docker-container-task">
|
||||
{containerTaskLabel(container)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -84,6 +84,7 @@ test.describe("Docker executor profile persistence", () => {
|
||||
labels: {
|
||||
"kandev.executor_profile_id": profile.id,
|
||||
"kandev.task_id": "task-abc123",
|
||||
"kandev.task_title": "Readable Task Title",
|
||||
"kandev.task_environment_id": "env-123",
|
||||
},
|
||||
},
|
||||
@@ -117,7 +118,7 @@ test.describe("Docker executor profile persistence", () => {
|
||||
|
||||
const row = testPage.getByTestId("docker-container-row-container-1");
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
await expect(row.getByTestId("docker-container-task")).toContainText("task-abc123");
|
||||
await expect(row.getByTestId("docker-container-task")).toContainText("Readable Task Title");
|
||||
await expect(row.getByText("Up 5 seconds")).toBeVisible();
|
||||
|
||||
await testPage.getByRole("button", { name: "Delete Profile" }).click();
|
||||
|
||||
Reference in New Issue
Block a user