fix(docker): show task titles in container lists

This commit is contained in:
José Almeida
2026-05-04 19:43:10 +01:00
parent 3b07d72a91
commit 2eed58f37c
11 changed files with 131 additions and 10 deletions
+16 -1
View File
@@ -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
+31 -5
View File
@@ -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();