feat: pre-filter Desktop Logs view by Compose project
Some checks failed
ci / validate (lint) (push) Has been cancelled
ci / validate (validate-docs) (push) Has been cancelled
ci / validate (validate-go-mod) (push) Has been cancelled
ci / validate (validate-headers) (push) Has been cancelled
ci / binary (push) Has been cancelled
ci / bin-image-test (push) Has been cancelled
ci / test (push) Has been cancelled
ci / e2e (plugin, oldstable) (push) Has been cancelled
ci / e2e (standalone, oldstable) (push) Has been cancelled
ci / e2e (plugin, stable) (push) Has been cancelled
ci / e2e (standalone, stable) (push) Has been cancelled
docs-upstream / docs-yaml (push) Has been cancelled
merge / bin-image-prepare (push) Has been cancelled
merge / module-image (push) Has been cancelled
Scorecards supply-chain security / Scorecards analysis (push) Has been cancelled
ci / binary-finalize (push) Has been cancelled
ci / coverage (push) Has been cancelled
ci / release (push) Has been cancelled
docs-upstream / validate (push) Has been cancelled
merge / bin-image (push) Has been cancelled
merge / desktop-edge-test (push) Has been cancelled

Pass the active project name as the appId query parameter on the
docker-desktop://dashboard/logs deep link, both from the post-command
hint (compose up -d, compose logs) and the interactive nav menu
('l' key during compose up). The hook subprocess re-runs compose-go's
project loader so the name matches what the parent computed; it skips
the appId when -p, -f, --project-directory, --workdir, or --env-file
is set, since the hook payload does not carry their values. docker
logs stays unfiltered: the CLI hook contract does not expose the
positional container id.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
This commit is contained in:
Guillaume Lours 2026-05-06 17:08:03 +02:00 committed by Guillaume Lours
parent 554a2ba3e8
commit c59e13cde5
5 changed files with 296 additions and 39 deletions

View file

@ -21,7 +21,9 @@ import (
"encoding/json"
"io"
"os"
"time"
"github.com/compose-spec/compose-go/v2/cli"
"github.com/docker/cli/cli-plugins/hooks"
"github.com/docker/cli/cli-plugins/metadata"
"github.com/spf13/cobra"
@ -30,14 +32,12 @@ import (
"github.com/docker/compose/v5/internal/desktop"
)
const deepLink = "docker-desktop://dashboard/logs"
func composeLogsHint() string {
return "Filter, search, and stream logs from all your Compose services\nin one place with Docker Desktop's Logs view. " + hintLink(deepLink)
func composeLogsHint(appID string) string {
return "Filter, search, and stream logs from all your Compose services\nin one place with Docker Desktop's Logs view. " + hintLink(desktop.BuildLogsURL(appID))
}
func dockerLogsHint() string {
return "View and search logs for all containers in one place\nwith Docker Desktop's Logs view. " + hintLink(deepLink)
func dockerLogsHint(appID string) string {
return "View and search logs for all containers in one place\nwith Docker Desktop's Logs view. " + hintLink(desktop.BuildLogsURL(appID))
}
// hintLink returns a clickable OSC 8 terminal hyperlink when ANSI is allowed,
@ -68,36 +68,89 @@ func shouldDisableAnsi() bool {
return false
}
// hookHint defines a hint that can be returned by the hooks handler.
// When checkFlags is nil, the hint is always returned for the matching command.
// When checkFlags is set, the hint is only returned if the check passes.
type hookHint struct {
template func() string
checkFlags func(flags map[string]string) bool
template func(appID string) string
checkFlags func(flags map[string]string) bool
resolveProject bool
}
// hooksHints maps hook root commands to their hint definitions. All current
// hints promote Docker Desktop's Logs view; emission is additionally gated on
// the FeatureLogsTab flag in handleHook.
var hooksHints = map[string]hookHint{
// standalone "docker logs" (not a compose subcommand)
// "docker logs": the CLI hook payload doesn't carry the positional
// container id, so the link is emitted unfiltered.
"logs": {template: dockerLogsHint},
"compose logs": {template: composeLogsHint},
"compose logs": {template: composeLogsHint, resolveProject: true},
"compose up": {
template: composeLogsHint,
template: composeLogsHint,
resolveProject: true,
checkFlags: func(flags map[string]string) bool {
// Only show the hint when running in detached mode
_, hasDetach := flags["detach"]
_, hasD := flags["d"]
return hasDetach || hasD
return hasFlag(flags, "detach", "d")
},
},
}
// logsTabEnabled reports whether Docker Desktop is the active engine and the
// LogsTab feature flag is enabled. Overridable for tests.
var logsTabEnabled = func(ctx context.Context) bool {
return desktop.IsFeatureActiveStandalone(ctx, desktop.FeatureLogsTab)
// Test seams. Replace via t.Cleanup; not safe to mutate from t.Parallel().
var (
logsTabEnabled = func(ctx context.Context) bool {
return desktop.IsFeatureActiveStandalone(ctx, desktop.FeatureLogsTab)
}
resolveAppID = defaultResolveAppID
)
const projectNameResolveTimeout = 250 * time.Millisecond
// Root-command flags whose values change which project the loader would
// resolve. The hook payload exposes flag names but not values, so when any
// is set we skip the appId rather than emit a wrong filter. workdir is the
// deprecated alias for --project-directory; env-file can set
// COMPOSE_PROJECT_NAME via the .env file it points at.
var projectScopingFlags = []string{
"project-name", "p",
"file", "f",
"project-directory", "workdir",
"env-file",
}
func defaultResolveAppID(ctx context.Context, flags map[string]string) string {
workDir, err := os.Getwd()
if err != nil {
return ""
}
return resolveAppIDIn(ctx, flags, workDir)
}
// Split from defaultResolveAppID so tests can pass a t.TempDir() instead
// of mutating process state via t.Chdir.
func resolveAppIDIn(ctx context.Context, flags map[string]string, workDir string) string {
if hasFlag(flags, projectScopingFlags...) {
return ""
}
ctx, cancel := context.WithTimeout(ctx, projectNameResolveTimeout)
defer cancel()
opts, err := cli.NewProjectOptions(nil,
cli.WithWorkingDirectory(workDir),
cli.WithOsEnv,
cli.WithDotEnv,
cli.WithConfigFileEnv,
cli.WithDefaultConfigPath,
)
if err != nil {
return ""
}
project, err := opts.LoadProject(ctx)
if err != nil {
return ""
}
return project.Name
}
func hasFlag(flags map[string]string, names ...string) bool {
for _, n := range names {
if _, ok := flags[n]; ok {
return true
}
}
return false
}
// HooksCommand returns the hidden subcommand that the Docker CLI invokes
@ -141,10 +194,15 @@ func handleHook(ctx context.Context, args []string, w io.Writer) error {
return nil
}
var appID string
if hint.resolveProject {
appID = resolveAppID(ctx, hookData.Flags)
}
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
return enc.Encode(hooks.Response{
Type: hooks.NextSteps,
Template: hint.template(),
Template: hint.template(appID),
})
}

View file

@ -21,6 +21,7 @@ import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
@ -28,13 +29,18 @@ import (
"gotest.tools/v3/assert"
"github.com/docker/compose/v5/cmd/formatter"
"github.com/docker/compose/v5/internal/desktop"
)
// TestMain stubs the Docker Desktop feature-flag check so handleHook tests
// don't attempt a live engine call. Individual tests can still override
// isFeatureEnabled with their own stub + t.Cleanup to restore.
const testDeepLink = "docker-desktop://dashboard/logs"
// TestMain stubs the Docker Desktop feature-flag check and the project
// loader so handleHook tests don't make a live engine call or read a
// compose file from the test runner's working directory. Individual tests
// override either stub with t.Cleanup to restore.
func TestMain(m *testing.M) {
logsTabEnabled = func(context.Context) bool { return true }
resolveAppID = func(context.Context, map[string]string) string { return "" }
os.Exit(m.Run())
}
@ -65,7 +71,7 @@ func TestHandleHook_UnknownCommand(t *testing.T) {
func TestHandleHook_LogsCommand(t *testing.T) {
tests := []struct {
rootCmd string
wantHint func() string
wantHint func(appID string) string
}{
{rootCmd: "compose logs", wantHint: composeLogsHint},
{rootCmd: "logs", wantHint: dockerLogsHint},
@ -81,7 +87,7 @@ func TestHandleHook_LogsCommand(t *testing.T) {
msg := unmarshalResponse(t, buf.Bytes())
assert.Equal(t, msg.Type, hooks.NextSteps)
assert.Equal(t, msg.Template, tt.wantHint())
assert.Equal(t, msg.Template, tt.wantHint(""))
})
}
}
@ -125,7 +131,7 @@ func TestHandleHook_ComposeUpDetached(t *testing.T) {
if tt.wantHint {
msg := unmarshalResponse(t, buf.Bytes())
assert.Equal(t, msg.Template, composeLogsHint())
assert.Equal(t, msg.Template, composeLogsHint(""))
} else {
assert.Equal(t, buf.String(), "")
}
@ -146,8 +152,8 @@ func TestHandleHook_HintContainsOSC8Link(t *testing.T) {
msg := unmarshalResponse(t, buf.Bytes())
// Verify the template contains the OSC 8 hyperlink sequence
wantLink := formatter.OSC8Link(deepLink, deepLink)
assert.Assert(t, len(wantLink) > len(deepLink), "OSC8Link should wrap the URL with escape sequences")
wantLink := formatter.OSC8Link(testDeepLink, testDeepLink)
assert.Assert(t, len(wantLink) > len(testDeepLink), "OSC8Link should wrap the URL with escape sequences")
assert.Assert(t, strings.Contains(msg.Template, wantLink), "hint should contain OSC 8 hyperlink")
}
@ -162,10 +168,57 @@ func TestHandleHook_NoColorDisablesOsc8(t *testing.T) {
msg := unmarshalResponse(t, buf.Bytes())
// With NO_COLOR set, the hint should contain the plain URL without escape sequences
assert.Assert(t, strings.Contains(msg.Template, deepLink), "hint should contain the deep link URL")
assert.Assert(t, strings.Contains(msg.Template, testDeepLink), "hint should contain the deep link URL")
assert.Assert(t, !strings.Contains(msg.Template, "\033"), "hint should not contain ANSI escape sequences")
}
func TestHandleHook_AppIDEncodedInURL(t *testing.T) {
prev := resolveAppID
t.Cleanup(func() { resolveAppID = prev })
resolveAppID = func(context.Context, map[string]string) string { return "myapp" }
t.Setenv("NO_COLOR", "1") // emit a plain URL we can substring-match
for _, rootCmd := range []string{"compose logs", "compose up"} {
t.Run(rootCmd, func(t *testing.T) {
data := marshalHookData(t, hooks.Request{
RootCmd: rootCmd,
Flags: map[string]string{"d": "true"},
})
var buf bytes.Buffer
err := handleHook(t.Context(), []string{data}, &buf)
assert.NilError(t, err)
msg := unmarshalResponse(t, buf.Bytes())
assert.Assert(t, strings.Contains(msg.Template, desktop.BuildLogsURL("myapp")),
"hint should include the project-scoped URL, got %q", msg.Template)
})
}
}
func TestHandleHook_DockerLogsIgnoresAppID(t *testing.T) {
// resolveAppID is not consulted for "logs" because that hint isn't
// resolveProject; assert the URL stays paramless even if a stub
// would otherwise return a value.
prev := resolveAppID
t.Cleanup(func() { resolveAppID = prev })
resolveAppID = func(context.Context, map[string]string) string {
t.Fatalf("resolveAppID should not be called for docker logs")
return ""
}
t.Setenv("NO_COLOR", "1")
data := marshalHookData(t, hooks.Request{RootCmd: "logs"})
var buf bytes.Buffer
err := handleHook(t.Context(), []string{data}, &buf)
assert.NilError(t, err)
msg := unmarshalResponse(t, buf.Bytes())
assert.Assert(t, strings.Contains(msg.Template, testDeepLink),
"docker logs hint should contain the bare deep link")
assert.Assert(t, !strings.Contains(msg.Template, "?appId="),
"docker logs hint must not encode an appId")
}
func TestHandleHook_FeatureFlagDisabledSuppressesHint(t *testing.T) {
prev := logsTabEnabled
t.Cleanup(func() { logsTabEnabled = prev })
@ -192,10 +245,87 @@ func TestHandleHook_ComposeAnsiNeverDisablesOsc8(t *testing.T) {
assert.NilError(t, err)
msg := unmarshalResponse(t, buf.Bytes())
assert.Assert(t, strings.Contains(msg.Template, deepLink), "hint should contain the deep link URL")
assert.Assert(t, strings.Contains(msg.Template, testDeepLink), "hint should contain the deep link URL")
assert.Assert(t, !strings.Contains(msg.Template, "\033"), "hint should not contain ANSI escape sequences")
}
func TestResolveAppID_ShortCircuitsOnFlag(t *testing.T) {
tests := []struct {
name string
flags map[string]string
}{
{name: "long --project-name", flags: map[string]string{"project-name": ""}},
{name: "short -p", flags: map[string]string{"p": ""}},
{name: "long --file", flags: map[string]string{"file": ""}},
{name: "short -f", flags: map[string]string{"f": ""}},
{name: "long --project-directory", flags: map[string]string{"project-directory": ""}},
{name: "deprecated --workdir alias", flags: map[string]string{"workdir": ""}},
{name: "long --env-file", flags: map[string]string{"env-file": ""}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Use a real tmpdir as workDir so the short-circuit path is
// exercised independently of the loader's file discovery.
got := resolveAppIDIn(t.Context(), tt.flags, t.TempDir())
assert.Equal(t, got, "")
})
}
}
func TestResolveAppID_NameFromComposeFile(t *testing.T) {
dir := t.TempDir()
mustWrite(t, dir, "compose.yaml", "name: from-yaml\nservices:\n svc:\n image: nginx\n")
unsetEnv(t, "COMPOSE_PROJECT_NAME")
unsetEnv(t, "COMPOSE_FILE")
got := resolveAppIDIn(t.Context(), nil, dir)
assert.Equal(t, got, "from-yaml")
}
func TestResolveAppID_EnvVarOverridesYAML(t *testing.T) {
dir := t.TempDir()
mustWrite(t, dir, "compose.yaml", "name: from-yaml\nservices:\n svc:\n image: nginx\n")
t.Setenv("COMPOSE_PROJECT_NAME", "from-env")
unsetEnv(t, "COMPOSE_FILE")
got := resolveAppIDIn(t.Context(), nil, dir)
assert.Equal(t, got, "from-env")
}
func TestResolveAppID_NoComposeFileReturnsEmpty(t *testing.T) {
unsetEnv(t, "COMPOSE_PROJECT_NAME")
unsetEnv(t, "COMPOSE_FILE")
got := resolveAppIDIn(t.Context(), nil, t.TempDir())
assert.Equal(t, got, "")
}
// unsetEnv removes an env var for the lifetime of the test, restoring its
// prior state on cleanup. t.Setenv("", "") is not equivalent to unset:
// compose-go's WithConfigFileEnv treats empty as a meaningful override.
func unsetEnv(t *testing.T, key string) {
t.Helper()
prev, had := os.LookupEnv(key)
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unsetenv %s: %v", key, err)
}
t.Cleanup(func() {
if !had {
return
}
if err := os.Setenv(key, prev); err != nil {
t.Errorf("restore env %s: %v", key, err)
}
})
}
func mustWrite(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
func marshalHookData(t *testing.T, data hooks.Request) string {
t.Helper()
b, err := json.Marshal(data)

View file

@ -31,6 +31,7 @@ import (
"github.com/eiannone/keyboard"
"github.com/skratchdot/open-golang/open"
"github.com/docker/compose/v5/internal/desktop"
"github.com/docker/compose/v5/internal/tracing"
"github.com/docker/compose/v5/pkg/api"
)
@ -238,14 +239,14 @@ func (lk *LogKeyboard) openDDComposeUI(ctx context.Context, project *types.Proje
}()
}
func (lk *LogKeyboard) openDDLogsView(ctx context.Context) {
func (lk *LogKeyboard) openDDLogsView(ctx context.Context, project *types.Project) {
if !lk.IsLogsViewEnabled {
return
}
go func() {
_ = tracing.EventWrapFuncForErrGroup(ctx, "menu/gui/logsview", tracing.SpanOptions{},
func(ctx context.Context) error {
link := "docker-desktop://dashboard/logs"
link := desktop.BuildLogsURL(project.Name)
err := open.Run(link)
if err != nil {
err = fmt.Errorf("could not open Docker Desktop Logs view: %w", err)
@ -336,7 +337,7 @@ func (lk *LogKeyboard) HandleKeyEvents(ctx context.Context, event keyboard.KeyEv
case 'o':
lk.openDDComposeUI(ctx, project)
case 'l':
lk.openDDLogsView(ctx)
lk.openDDLogsView(ctx, project)
}
switch key := event.Key; key {
case keyboard.KeyCtrlC:

View file

@ -23,6 +23,7 @@ import (
"io"
"net"
"net/http"
"net/url"
"strings"
"github.com/docker/cli/cli/command"
@ -42,6 +43,31 @@ const EngineLabel = "com.docker.desktop.address"
// FeatureLogsTab is the feature flag name for the Docker Desktop Logs view.
const FeatureLogsTab = "LogsTab"
const logsDeepLink = "docker-desktop://dashboard/logs"
// LogsAppIDMaxLen mirrors the byte-length cap Docker Desktop's URL handler
// applies to the appId query parameter; values longer than this are
// truncated by the receiver, so we trim ahead of time to avoid emitting
// hyperlinks that will be silently shortened. The slice in BuildLogsURL is
// a byte slice — Compose project names are restricted to the ASCII set
// `[a-z0-9_-]` by loader.NormalizeProjectName, so a byte cap and a rune
// cap coincide for any value that could legitimately reach this builder.
const LogsAppIDMaxLen = 256
// BuildLogsURL returns the deep link that opens Docker Desktop's Logs view,
// optionally pre-filtered to a Compose project. An empty appID yields the
// unfiltered URL.
func BuildLogsURL(appID string) string {
if appID == "" {
return logsDeepLink
}
if len(appID) > LogsAppIDMaxLen {
appID = appID[:LogsAppIDMaxLen]
}
q := url.Values{"appId": []string{appID}}
return logsDeepLink + "?" + q.Encode()
}
// identify this client in the logs
var userAgent = "compose/" + internal.Version

View file

@ -18,12 +18,54 @@ package desktop
import (
"os"
"strings"
"testing"
"time"
"gotest.tools/v3/assert"
)
func TestBuildLogsURL(t *testing.T) {
tests := []struct {
name string
appID string
want string
}{
{
name: "empty app id yields paramless url",
appID: "",
want: "docker-desktop://dashboard/logs",
},
{
name: "simple project name",
appID: "myapp",
want: "docker-desktop://dashboard/logs?appId=myapp",
},
{
name: "name with hyphen and digits is preserved",
appID: "my-app-2",
want: "docker-desktop://dashboard/logs?appId=my-app-2",
},
{
name: "characters that need percent-encoding are escaped",
appID: "weird name/with spaces",
want: "docker-desktop://dashboard/logs?appId=weird+name%2Fwith+spaces",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, BuildLogsURL(tt.appID), tt.want)
})
}
}
func TestBuildLogsURL_TruncatesLongAppID(t *testing.T) {
long := strings.Repeat("a", LogsAppIDMaxLen+50)
got := BuildLogsURL(long)
want := "docker-desktop://dashboard/logs?appId=" + strings.Repeat("a", LogsAppIDMaxLen)
assert.Equal(t, got, want)
}
func TestClientPing(t *testing.T) {
if testing.Short() {
t.Skip("Skipped in short mode - test connects to Docker Desktop")