mirror of
https://github.com/jesseduffield/lazydocker.git
synced 2026-07-22 07:11:01 +00:00
Merge 1f035a8d6d into 7e7aadc207
This commit is contained in:
commit
686b980a0f
14 changed files with 611 additions and 33 deletions
|
|
@ -70,6 +70,7 @@ commandTemplates:
|
|||
up: '{{ .DockerCompose }} up -d'
|
||||
down: '{{ .DockerCompose }} down'
|
||||
downWithVolumes: '{{ .DockerCompose }} down --volumes'
|
||||
restart: '{{ .DockerCompose }} restart'
|
||||
upService: '{{ .DockerCompose }} up -d {{ .Service.Name }}'
|
||||
startService: '{{ .DockerCompose }} start {{ .Service.Name }}'
|
||||
stopService: '{{ .DockerCompose }} stop {{ .Service.Name }}'
|
||||
|
|
@ -95,6 +96,20 @@ stats:
|
|||
color: green
|
||||
```
|
||||
|
||||
## Docker Compose profiles
|
||||
|
||||
When a docker-compose profile is selected in the project panel, the project-scoped
|
||||
templates (`up`, `down`, `downWithVolumes`, `restart`, `allLogs`, `viewAlLogs`,
|
||||
`dockerComposeConfig`) automatically have `--profile <name>` appended via the
|
||||
`{{ .DockerCompose }}` substitution. There are no separate profile templates
|
||||
to configure — any custom override of those templates will pick up the profile
|
||||
flag for free.
|
||||
|
||||
Selecting a profile row in the project panel also filters the services and
|
||||
containers panels to the services that activate with that profile (default
|
||||
services plus profile-tagged services, matching `docker compose --profile X
|
||||
config --services`).
|
||||
|
||||
## To see what all of the config options mean, and what other options you can set, see [here](https://godoc.org/github.com/jesseduffield/lazydocker/pkg/config)
|
||||
|
||||
## Color Attributes:
|
||||
|
|
|
|||
90
docs/adr/0001-docker-compose-profile-support.md
Normal file
90
docs/adr/0001-docker-compose-profile-support.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# 1. Docker-compose profile support
|
||||
|
||||
## Status
|
||||
|
||||
Proposed.
|
||||
|
||||
## Context
|
||||
|
||||
[Issue #423](https://github.com/jesseduffield/lazydocker/issues/423) requests that lazydocker understand docker-compose [profiles](https://docs.docker.com/compose/profiles/). Today it shows every service in the project, with no way to scope to a specific profile and no way to invoke profile-aware `up`/`down`/`restart`.
|
||||
|
||||
A community PR — [#638](https://github.com/jesseduffield/lazydocker/pull/638), branch `saravanabalagi/feat/docker_profiles` — attempted this in March 2025 and has sat open since. Two problems with picking it up:
|
||||
|
||||
1. **Master moved on substantially.** Since the PR was authored, master has gained multi-project discovery from container labels (`com.docker.compose.project`), a project-filter on the services and containers panels, a `Hide` on the project panel keyed off `IsProjectScoped()`, and a `-p` flag injection in `NewCommandObject`. The PR's structural changes overlap with those refactors; merging or rebasing produces large, gnarly conflicts in `project_panel.go`.
|
||||
2. **The PR has a latent bug.** It adds a `CheckDockerComposeProfiles` template that uses a shell pipeline (`... | grep -q ...; echo $?`), but `OSCommand.RunCommand` parses commands with `str.ToArgv` and exec's directly — no shell. So the precheck always fails, `HasProfiles` stays false, and the UI never shows profile rows even though the rest of the feature is wired up. Anyone testing the branch sees "nothing happens".
|
||||
|
||||
The cleanest path is to redesign on master rather than rescue the PR. Master's `getDiscoveredProjects()` already gives us a list-of-rows model in the project panel; profiles slot in as additional rows of the same `*commands.Project` type. And master's `NewCommandObject` already injects `-p <name>` from a `Project`/`Service` field — we can extend the same mechanism to inject `--profile <name>`, removing the need for the PR's separate `UpProfile`/`DownProfile`/... template family.
|
||||
|
||||
## Decision
|
||||
|
||||
Implement profile support as an additive change on master, with the following architectural choices:
|
||||
|
||||
### Profile is a flag on `Project`, not a new type
|
||||
|
||||
Add `IsProfile bool` to `pkg/commands/project.go`'s `Project` struct. Profiles render as pseudo-projects in the panel. This avoids a generic-type split across the `SideListPanel[*commands.Project]`, `CommandObject`, presentation, services and containers panels — all of which already pass `*Project` around.
|
||||
|
||||
### Reuse existing command templates; inject `--profile X` via `NewCommandObject`
|
||||
|
||||
Add a `Profile string` field to `CommandObject` and have `NewCommandObject` append `--profile X` (in addition to the existing `-p <name>` for the local project) when a profile context is set. This means the existing `Up`, `Down`, `DownWithVolumes`, `AllLogs`, `DockerComposeConfig` templates work unchanged for profile invocations — no `UpProfile` / `DownProfile` / `RestartProfile` / `AllLogsProfile` template family. The single new template is `Restart` (master doesn't have a project-level restart at all today).
|
||||
|
||||
This is the key reason the change is small. The original PR added ~12 new templates and ~12 corresponding i18n strings; this design needs one new template and a handful of i18n strings (for confirmation/status messages on profile actions).
|
||||
|
||||
### Discovery via shelling out, cached for the session
|
||||
|
||||
Add `GetProfiles()` and `GetProfileServices(profile)` methods on `DockerCommand` that shell out to `docker compose config --profiles` and `docker compose --profile X config --services` respectively. Cache results on the `DockerCommand` struct under a mutex; never invalidate during a session. Compose files rarely change mid-session, and `GetServices()` already has the same property — users restart lazydocker to pick up changes.
|
||||
|
||||
**No precheck.** The PR's `CheckDockerComposeProfiles` template (which was the bug source) is unnecessary — we just enumerate. Empty result → no profile rows. This also means no shell-pipe escape hatch is needed; everything stays compatible with `OSCommand.RunCommand`'s argv-exec model.
|
||||
|
||||
### Soft-fail discovery
|
||||
|
||||
If `GetProfiles()` errors (e.g. `docker compose` is missing, compose v1 too old), log once and cache empty. No popup, no warning row. The UI degrades to "no profile rows" — same UX as a compose project with no profiles declared. This matches master's existing `GetServices()` behaviour.
|
||||
|
||||
### Selecting a profile filters the services and containers panels
|
||||
|
||||
Master's project filter on `services_panel.go` and `containers_panel.go` keeps items where `ProjectName == selectedProject`. Add a profile branch: when a profile is selected, keep items where `ProjectName == LocalProjectName` AND `ServiceName ∈ GetProfileServices(profile)`. The set returned by `docker compose --profile X config --services` includes the profile's services plus the default (no-profile) services — that's docker's own semantics and matches what users expect ("show me what would be running if I activated this profile").
|
||||
|
||||
### Profiles render after projects in the panel
|
||||
|
||||
Sort callback returns non-profiles first, then alphabetical within each group. No per-project grouping (profiles always belong to the local project, of which there is exactly one). Selection restore keys on the `(Name, IsProfile)` tuple so a profile that happens to share a project's name doesn't cross-select.
|
||||
|
||||
### Visual: cyan "profile" prefix label
|
||||
|
||||
Mirrors the existing two-column row layout. Re-uses `utils.ColoredString(..., color.FgCyan)`.
|
||||
|
||||
### English-only i18n initially
|
||||
|
||||
Add new strings to `pkg/i18n/english.go`. If `go build ./...` shows other locales fail (they shouldn't — they inherit empty strings for new fields by default), backfill defaults; otherwise leave localization for a follow-up.
|
||||
|
||||
### Scope limits
|
||||
|
||||
- No new keybinding for project/profile restart in this PR — leave for maintainer discussion. The handler exists; binding it is one line.
|
||||
- No profile-aware custom service/container commands. A service belongs to a specific project, not a profile; threading profile context into per-service custom commands would change semantics surprisingly.
|
||||
- No cache invalidation on compose-file change. Defer until requested.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Small diff.** ~80-100 lines added across 5-6 files, vs. the PR's ~350 lines. Easier review, easier maintenance.
|
||||
- **No new template family.** `Up`/`Down`/`Restart`/`AllLogs`/`DockerComposeConfig` work unchanged for profile invocations because `--profile X` is injected at the `NewCommandObject` layer. Users who've customized these templates get profile support for free.
|
||||
- **No latent shell-pipe bug.** No precheck template; no shell-metacharacter dependencies in `OSCommand.RunCommand`.
|
||||
- **Slots into master's existing architecture.** Profiles are just more rows; selection still flows through `OnSelect`; filter still flows through the existing `Filter` callback. No new panel, no new lifecycle.
|
||||
- **PR-able cleanly upstream.** Additive across well-bounded extension points.
|
||||
|
||||
### Negative / risks
|
||||
|
||||
- **Re-shells `docker compose config` once per session.** Both `--profiles` (once) and `--profile X --services` (once per profile selected). Both can be slow on large compose files. Mitigated by single-call caching.
|
||||
- **`Restart` template is new.** Users with a heavily customized `~/.config/lazydocker/config.yml` that wipes defaults will need to add it. Default value is `"{{ .DockerCompose }} restart"` — boring and obvious.
|
||||
- **Selection-restore tuple change.** If anything downstream of `refreshProject` assumes project names are unique within the panel, it breaks. Audit shows nothing relies on this today, but worth noting in PR review.
|
||||
- **No per-profile cache invalidation.** If a user edits `compose.yml` to add a new profile mid-session, lazydocker won't see it until restart. Same constraint as `GetServices()`.
|
||||
- **Profile-aware filtering issues an extra shell call per profile selection.** At 100ms refresh tick and cached results, this is one shell per profile per session — negligible.
|
||||
|
||||
### Risks if rejected
|
||||
|
||||
If upstream wants separate `UpProfile`/etc. templates for customization symmetry with the existing pattern, the design has to grow back toward the original PR's shape. Worth raising in PR discussion before sinking time into the larger version.
|
||||
|
||||
## References
|
||||
|
||||
- Issue: [jesseduffield/lazydocker#423](https://github.com/jesseduffield/lazydocker/issues/423)
|
||||
- Prior PR (stale): [jesseduffield/lazydocker#638](https://github.com/jesseduffield/lazydocker/pull/638)
|
||||
- Implementation plan: see this repo's branch and corresponding PR.
|
||||
2
mise.toml
Normal file
2
mise.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[tools]
|
||||
go = "1.26.3"
|
||||
|
|
@ -47,6 +47,19 @@ type DockerCommand struct {
|
|||
ContainerMutex deadlock.Mutex
|
||||
ServiceMutex deadlock.Mutex
|
||||
|
||||
// Profile discovery cache. Populated lazily; never invalidated within a
|
||||
// session — compose file changes are rare and the user can restart
|
||||
// lazydocker. Mirrors the existing GetServices() pattern.
|
||||
profileMutex deadlock.Mutex
|
||||
profilesCached bool
|
||||
profiles []string
|
||||
profileSvcs map[string][]string
|
||||
|
||||
// runOutput is an injection seam for tests. Production code leaves it
|
||||
// nil and falls through to OSCommand.RunCommandWithOutput. Tests can
|
||||
// set this to return canned output without spinning up a real shell.
|
||||
runOutput func(string) (string, error)
|
||||
|
||||
Closers []io.Closer
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +79,7 @@ type CommandObject struct {
|
|||
Volume *Volume
|
||||
Network *Network
|
||||
Project *Project
|
||||
Profile string
|
||||
}
|
||||
|
||||
// NewCommandObject takes a command object and returns a default command object with the passed command object merged in
|
||||
|
|
@ -77,10 +91,25 @@ func (c *DockerCommand) NewCommandObject(obj CommandObject) CommandObject {
|
|||
// docker compose targets the correct project.
|
||||
if obj.Service != nil && obj.Service.ProjectName != "" {
|
||||
defaultObj.DockerCompose = fmt.Sprintf("%s -p %s", defaultObj.DockerCompose, obj.Service.ProjectName)
|
||||
} else if obj.Project != nil && obj.Project.Name != "" {
|
||||
} else if obj.Project != nil && obj.Project.Name != "" && !obj.Project.IsProfile {
|
||||
defaultObj.DockerCompose = fmt.Sprintf("%s -p %s", defaultObj.DockerCompose, obj.Project.Name)
|
||||
}
|
||||
|
||||
// Profile pseudo-projects always belong to the local project, so emit
|
||||
// `-p <LocalProjectName> --profile <name>`. Allow the caller to supply
|
||||
// the profile name either via the explicit Profile field or via a
|
||||
// Project{IsProfile: true} pseudo-project.
|
||||
profile := obj.Profile
|
||||
if profile == "" && obj.Project != nil && obj.Project.IsProfile {
|
||||
profile = obj.Project.Name
|
||||
}
|
||||
if profile != "" {
|
||||
if c.LocalProjectName != "" && !strings.Contains(defaultObj.DockerCompose, " -p ") {
|
||||
defaultObj.DockerCompose = fmt.Sprintf("%s -p %s", defaultObj.DockerCompose, c.LocalProjectName)
|
||||
}
|
||||
defaultObj.DockerCompose = fmt.Sprintf("%s --profile %s", defaultObj.DockerCompose, profile)
|
||||
}
|
||||
|
||||
return defaultObj
|
||||
}
|
||||
|
||||
|
|
@ -490,12 +519,14 @@ func (c *DockerCommand) SetContainerDetails(containers []*Container) {
|
|||
wg.Wait()
|
||||
}
|
||||
|
||||
// ViewAllLogs attaches to a subprocess viewing all the logs from docker-compose
|
||||
func (c *DockerCommand) ViewAllLogs(project *Project) (*exec.Cmd, error) {
|
||||
// ViewAllLogs attaches to a subprocess viewing all the logs from docker-compose.
|
||||
// The caller supplies a CommandObject so it can request a profile-scoped view
|
||||
// (`{Profile: name}`) or a plain project view (`{Project: project}`).
|
||||
func (c *DockerCommand) ViewAllLogs(obj CommandObject) (*exec.Cmd, error) {
|
||||
cmd := c.OSCommand.ExecutableFromString(
|
||||
utils.ApplyTemplate(
|
||||
c.OSCommand.Config.UserConfig.CommandTemplates.ViewAllLogs,
|
||||
c.NewCommandObject(CommandObject{Project: project}),
|
||||
c.NewCommandObject(obj),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -523,6 +554,94 @@ func (c *DockerCommand) DockerComposeConfigForProject(project *Project) string {
|
|||
return output
|
||||
}
|
||||
|
||||
// DockerComposeConfigForProfile renders the resolved compose YAML with a
|
||||
// specific profile activated. The existing DockerComposeConfig template is
|
||||
// reused; NewCommandObject appends `--profile <name>`.
|
||||
func (c *DockerCommand) DockerComposeConfigForProfile(profile string) string {
|
||||
output, err := c.OSCommand.RunCommandWithOutput(
|
||||
utils.ApplyTemplate(
|
||||
c.OSCommand.Config.UserConfig.CommandTemplates.DockerComposeConfig,
|
||||
c.NewCommandObject(CommandObject{Profile: profile}),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
output = err.Error()
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
// runCmdOutput is the injection seam used by the profile-discovery methods.
|
||||
func (c *DockerCommand) runCmdOutput(command string) (string, error) {
|
||||
if c.runOutput != nil {
|
||||
return c.runOutput(command)
|
||||
}
|
||||
return c.OSCommand.RunCommandWithOutput(command)
|
||||
}
|
||||
|
||||
// GetProfiles returns the docker-compose profiles declared in the local
|
||||
// compose project. Cached per session. Returns nil when not in a compose
|
||||
// project. Soft-fails: an error from the underlying compose call is logged
|
||||
// once and the cache is marked populated with an empty list, so the UI
|
||||
// degrades to "no profile rows".
|
||||
func (c *DockerCommand) GetProfiles() ([]string, error) {
|
||||
c.profileMutex.Lock()
|
||||
defer c.profileMutex.Unlock()
|
||||
if c.profilesCached {
|
||||
return c.profiles, nil
|
||||
}
|
||||
c.profilesCached = true
|
||||
if !c.InDockerComposeProject {
|
||||
return nil, nil
|
||||
}
|
||||
composeCmd := c.Config.UserConfig.CommandTemplates.DockerCompose
|
||||
output, err := c.runCmdOutput(fmt.Sprintf("%s config --profiles", composeCmd))
|
||||
if err != nil {
|
||||
c.Log.Warn("Failed to list compose profiles: " + err.Error())
|
||||
return nil, nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, line := range utils.SplitLines(strings.TrimSpace(output)) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || seen[line] {
|
||||
continue
|
||||
}
|
||||
seen[line] = true
|
||||
c.profiles = append(c.profiles, line)
|
||||
}
|
||||
sort.Strings(c.profiles)
|
||||
return c.profiles, nil
|
||||
}
|
||||
|
||||
// GetProfileServices returns the service names that activate when the given
|
||||
// profile is enabled (per docker compose semantics: default services + that
|
||||
// profile's services). Cached per profile for the session.
|
||||
func (c *DockerCommand) GetProfileServices(profile string) ([]string, error) {
|
||||
c.profileMutex.Lock()
|
||||
defer c.profileMutex.Unlock()
|
||||
if c.profileSvcs == nil {
|
||||
c.profileSvcs = map[string][]string{}
|
||||
}
|
||||
if svcs, ok := c.profileSvcs[profile]; ok {
|
||||
return svcs, nil
|
||||
}
|
||||
composeCmd := c.Config.UserConfig.CommandTemplates.DockerCompose
|
||||
output, err := c.runCmdOutput(fmt.Sprintf("%s --profile %s config --services", composeCmd, profile))
|
||||
if err != nil {
|
||||
// Cache the empty slice so we don't re-shell every refresh tick.
|
||||
c.profileSvcs[profile] = []string{}
|
||||
return nil, err
|
||||
}
|
||||
lines := []string{}
|
||||
for _, line := range utils.SplitLines(strings.TrimSpace(output)) {
|
||||
if line = strings.TrimSpace(line); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
sort.Strings(lines)
|
||||
c.profileSvcs[profile] = lines
|
||||
return lines, nil
|
||||
}
|
||||
|
||||
// determineDockerHost tries to the determine the docker host that we should connect to
|
||||
// in the following order of decreasing precedence:
|
||||
// - value of "DOCKER_HOST" environment variable
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package commands
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
|
|
@ -89,3 +90,139 @@ func TestIsProjectScoped(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewCommandObjectProfileInjection verifies that NewCommandObject appends
|
||||
// `-p <LocalProjectName>` and `--profile <name>` correctly for the various
|
||||
// ways a caller can identify a profile, and that the existing project/service
|
||||
// `-p` injection isn't disturbed by the new code paths.
|
||||
func TestNewCommandObjectProfileInjection(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
localProjectName string
|
||||
obj CommandObject
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no project, no profile",
|
||||
obj: CommandObject{},
|
||||
want: "docker compose",
|
||||
},
|
||||
{
|
||||
name: "profile via Profile field with LocalProjectName",
|
||||
localProjectName: "myapp",
|
||||
obj: CommandObject{Profile: "dev"},
|
||||
want: "docker compose -p myapp --profile dev",
|
||||
},
|
||||
{
|
||||
name: "profile via Project pseudo-project with LocalProjectName",
|
||||
localProjectName: "myapp",
|
||||
obj: CommandObject{Project: &Project{Name: "dev", IsProfile: true}},
|
||||
want: "docker compose -p myapp --profile dev",
|
||||
},
|
||||
{
|
||||
name: "non-profile Project still gets -p with its own name",
|
||||
obj: CommandObject{Project: &Project{Name: "other"}},
|
||||
want: "docker compose -p other",
|
||||
},
|
||||
{
|
||||
name: "Service supplies -p; profile only adds --profile",
|
||||
obj: CommandObject{
|
||||
Service: &Service{ProjectName: "svcproj"},
|
||||
Profile: "dev",
|
||||
},
|
||||
want: "docker compose -p svcproj --profile dev",
|
||||
},
|
||||
{
|
||||
name: "profile without LocalProjectName emits --profile only",
|
||||
localProjectName: "",
|
||||
obj: CommandObject{Profile: "dev"},
|
||||
want: "docker compose --profile dev",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
c := newTestDockerCommand(nil)
|
||||
c.LocalProjectName = tc.localProjectName
|
||||
got := c.NewCommandObject(tc.obj).DockerCompose
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetProfilesCaching exercises the per-session cache: the underlying
|
||||
// command is invoked exactly once even across repeated calls, output is
|
||||
// split/sorted/deduped, and the !InDockerComposeProject path short-circuits.
|
||||
func TestGetProfilesCaching(t *testing.T) {
|
||||
t.Run("caches across calls, dedupes and sorts", func(t *testing.T) {
|
||||
calls := 0
|
||||
c := newTestDockerCommand(func(cmd string) (string, error) {
|
||||
calls++
|
||||
assert.Equal(t, "docker compose config --profiles", cmd)
|
||||
return "regulator\nobligations\nregulator\n", nil
|
||||
})
|
||||
got1, err := c.GetProfiles()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"obligations", "regulator"}, got1)
|
||||
got2, err := c.GetProfiles()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, got1, got2)
|
||||
assert.Equal(t, 1, calls)
|
||||
})
|
||||
t.Run("short-circuits when not in compose project", func(t *testing.T) {
|
||||
calls := 0
|
||||
c := newTestDockerCommand(func(cmd string) (string, error) {
|
||||
calls++
|
||||
return "", nil
|
||||
})
|
||||
c.InDockerComposeProject = false
|
||||
got, err := c.GetProfiles()
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, got)
|
||||
assert.Equal(t, 0, calls)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetProfileServicesCaching ensures the per-profile cache invokes the
|
||||
// underlying command once per distinct profile, not once per call.
|
||||
func TestGetProfileServicesCaching(t *testing.T) {
|
||||
calls := 0
|
||||
c := newTestDockerCommand(func(cmd string) (string, error) {
|
||||
calls++
|
||||
switch cmd {
|
||||
case "docker compose --profile a config --services":
|
||||
return "svc-a1\nsvc-a2\n", nil
|
||||
case "docker compose --profile b config --services":
|
||||
return "svc-b\n", nil
|
||||
}
|
||||
t.Fatalf("unexpected command: %q", cmd)
|
||||
return "", nil
|
||||
})
|
||||
a1, err := c.GetProfileServices("a")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"svc-a1", "svc-a2"}, a1)
|
||||
a2, err := c.GetProfileServices("a")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, a1, a2)
|
||||
b1, err := c.GetProfileServices("b")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []string{"svc-b"}, b1)
|
||||
assert.Equal(t, 2, calls, "one call per distinct profile, cached thereafter")
|
||||
}
|
||||
|
||||
// TestGetProfilesSoftFailsOnError verifies that errors from the compose
|
||||
// shellout become a logged warning + empty cache rather than propagating
|
||||
// to the UI, and that a subsequent call doesn't retry.
|
||||
func TestGetProfilesSoftFailsOnError(t *testing.T) {
|
||||
calls := 0
|
||||
c := newTestDockerCommand(func(cmd string) (string, error) {
|
||||
calls++
|
||||
return "", errors.New("compose not installed")
|
||||
})
|
||||
got, err := c.GetProfiles()
|
||||
assert.NoError(t, err, "errors are soft-failed; UI sees empty list")
|
||||
assert.Nil(t, got)
|
||||
got2, err := c.GetProfiles()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, got, got2)
|
||||
assert.Equal(t, 1, calls, "error result is cached; no retry")
|
||||
}
|
||||
|
|
|
|||
21
pkg/commands/docker_test_helpers_test.go
Normal file
21
pkg/commands/docker_test_helpers_test.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package commands
|
||||
|
||||
import (
|
||||
"github.com/jesseduffield/lazydocker/pkg/config"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// newTestDockerCommand assembles a DockerCommand suitable for unit-testing the
|
||||
// profile-discovery and command-templating logic in isolation. It populates
|
||||
// just the fields those code paths touch: Config (with default user templates),
|
||||
// Log, and the runOutput injection seam used in place of the real OSCommand
|
||||
// shellout.
|
||||
func newTestDockerCommand(runOutput func(string) (string, error)) *DockerCommand {
|
||||
userCfg := config.GetDefaultConfig()
|
||||
return &DockerCommand{
|
||||
Log: logrus.NewEntry(logrus.New()),
|
||||
Config: &config.AppConfig{UserConfig: &userCfg},
|
||||
InDockerComposeProject: true,
|
||||
runOutput: runOutput,
|
||||
}
|
||||
}
|
||||
|
|
@ -2,4 +2,9 @@ package commands
|
|||
|
||||
type Project struct {
|
||||
Name string
|
||||
// IsProfile marks a row in the project panel as a docker-compose profile
|
||||
// pseudo-project. Profiles live within the local compose project; selecting
|
||||
// one filters the services panel to that profile and routes Up/Down/Restart
|
||||
// through the --profile flag.
|
||||
IsProfile bool
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,6 +166,11 @@ type CommandTemplatesConfig struct {
|
|||
// downs and removes volumes
|
||||
DownWithVolumes string `yaml:"downWithVolumes,omitempty"`
|
||||
|
||||
// Restart restarts the whole project. When a profile is selected in the
|
||||
// project panel, `--profile <name>` is appended automatically via the
|
||||
// CommandObject injection in NewCommandObject.
|
||||
Restart string `yaml:"restart,omitempty"`
|
||||
|
||||
// DockerCompose is for your docker-compose command. You may want to combine a
|
||||
// few different docker-compose.yml files together, in which case you can set
|
||||
// this to "docker compose -f foo/docker-compose.yml -f
|
||||
|
|
@ -391,6 +396,7 @@ func GetDefaultConfig() UserConfig {
|
|||
Up: "{{ .DockerCompose }} up -d",
|
||||
Down: "{{ .DockerCompose }} down",
|
||||
DownWithVolumes: "{{ .DockerCompose }} down --volumes",
|
||||
Restart: "{{ .DockerCompose }} restart",
|
||||
UpService: "{{ .DockerCompose }} up -d {{ .Service.Name }}",
|
||||
RebuildService: "{{ .DockerCompose }} up -d --build {{ .Service.Name }}",
|
||||
RecreateService: "{{ .DockerCompose }} up -d --force-recreate {{ .Service.Name }}",
|
||||
|
|
|
|||
|
|
@ -175,20 +175,25 @@ func (gui *Gui) sidePanelChildren(width int, height int) []*boxlayout.Box {
|
|||
return defaultBox
|
||||
}
|
||||
|
||||
// The project panel is compact (Size: 3) when not focused, but expands
|
||||
// when focused to show the list of projects. This only applies when the
|
||||
// project panel is actually visible (i.e. we are inside a compose project).
|
||||
// The project panel sizes to fit its rows (so profiles are visible
|
||||
// without having to click in). Capped so a project with many profiles
|
||||
// can't crowd out the other side panels. Routed through accordionBox
|
||||
// so it follows the same focus-expansion rule as the other panels
|
||||
// (only expands on focus when Gui.ExpandFocusedSidePanel is set).
|
||||
// This only applies when the project panel is actually visible
|
||||
// (i.e. we are inside a compose project).
|
||||
if len(sideWindowNames) > 0 && sideWindowNames[0] == "project" {
|
||||
projectBox := &boxlayout.Box{
|
||||
const maxProjectPanelSize = 8
|
||||
size := gui.Panels.Projects.List.Len() + 2
|
||||
if size < 3 {
|
||||
size = 3
|
||||
} else if size > maxProjectPanelSize {
|
||||
size = maxProjectPanelSize
|
||||
}
|
||||
projectBox := accordionBox(&boxlayout.Box{
|
||||
Window: sideWindowNames[0],
|
||||
Size: 3,
|
||||
}
|
||||
if currentWindow == sideWindowNames[0] {
|
||||
projectBox = &boxlayout.Box{
|
||||
Window: sideWindowNames[0],
|
||||
Weight: 2,
|
||||
}
|
||||
}
|
||||
Size: size,
|
||||
})
|
||||
|
||||
return append([]*boxlayout.Box{
|
||||
projectBox,
|
||||
|
|
|
|||
|
|
@ -103,6 +103,25 @@ func (gui *Gui) getContainersPanel() *panels.SideListPanel[*commands.Container]
|
|||
return false
|
||||
}
|
||||
|
||||
// Profile selected? scope to the local project's containers
|
||||
// whose service is in the profile's service set. Truly
|
||||
// standalone containers (no compose project) are shown.
|
||||
if profile := gui.getSelectedProfile(); profile != "" {
|
||||
if container.ProjectName == "" {
|
||||
return true
|
||||
}
|
||||
if container.ProjectName != gui.DockerCommand.LocalProjectName {
|
||||
return false
|
||||
}
|
||||
svcs, _ := gui.DockerCommand.GetProfileServices(profile)
|
||||
for _, n := range svcs {
|
||||
if container.ServiceName == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Filter by selected project. Containers with no project (truly
|
||||
// standalone, not from any compose project) are always shown.
|
||||
selectedProject := gui.getSelectedProjectName()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
package presentation
|
||||
|
||||
import "github.com/jesseduffield/lazydocker/pkg/commands"
|
||||
import (
|
||||
"github.com/fatih/color"
|
||||
"github.com/jesseduffield/lazydocker/pkg/commands"
|
||||
"github.com/jesseduffield/lazydocker/pkg/utils"
|
||||
)
|
||||
|
||||
func GetProjectDisplayStrings(project *commands.Project) []string {
|
||||
if project.IsProfile {
|
||||
return []string{utils.ColoredString("profile ", color.FgCyan) + project.Name}
|
||||
}
|
||||
return []string{project.Name}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ func (gui *Gui) getProjectPanel() *panels.SideListPanel[*commands.Project] {
|
|||
Gui: gui.intoInterface(),
|
||||
|
||||
Sort: func(a *commands.Project, b *commands.Project) bool {
|
||||
// Projects first (alphabetical), then profiles (alphabetical).
|
||||
if a.IsProfile != b.IsProfile {
|
||||
return !a.IsProfile
|
||||
}
|
||||
return a.Name < b.Name
|
||||
},
|
||||
GetTableCells: presentation.GetProjectDisplayStrings,
|
||||
|
|
@ -69,7 +73,10 @@ func (gui *Gui) refreshProject() error {
|
|||
|
||||
// Preserve the current selection across refreshes. On the first refresh,
|
||||
// select the project specified via -p flag, or fall back to the local project.
|
||||
// We match on (Name, IsProfile) so a profile that shares a project's name
|
||||
// doesn't cross-select with the project row.
|
||||
selectedName := gui.getSelectedProjectName()
|
||||
selectedIsProfile := gui.isSelectedProjectAProfile()
|
||||
if selectedName == "" {
|
||||
if gui.Config.ProjectName != "" {
|
||||
selectedName = gui.Config.ProjectName
|
||||
|
|
@ -82,7 +89,7 @@ func (gui *Gui) refreshProject() error {
|
|||
|
||||
if selectedName != "" {
|
||||
for i, p := range gui.Panels.Projects.List.GetItems() {
|
||||
if p.Name == selectedName {
|
||||
if p.Name == selectedName && p.IsProfile == selectedIsProfile {
|
||||
gui.Panels.Projects.SetSelectedLineIdx(i)
|
||||
gui.Panels.Projects.Refocus()
|
||||
break
|
||||
|
|
@ -119,9 +126,19 @@ func (gui *Gui) getDiscoveredProjects() []*commands.Project {
|
|||
}
|
||||
}
|
||||
|
||||
projects := make([]*commands.Project, len(projectNames))
|
||||
for i, name := range projectNames {
|
||||
projects[i] = &commands.Project{Name: name}
|
||||
projects := make([]*commands.Project, 0, len(projectNames))
|
||||
for _, name := range projectNames {
|
||||
projects = append(projects, &commands.Project{Name: name})
|
||||
}
|
||||
|
||||
// Append profile pseudo-projects for the local compose project. The Sort
|
||||
// callback above places these after all regular project rows.
|
||||
if gui.DockerCommand.InDockerComposeProject {
|
||||
if profileNames, _ := gui.DockerCommand.GetProfiles(); len(profileNames) > 0 {
|
||||
for _, p := range profileNames {
|
||||
projects = append(projects, &commands.Project{Name: p, IsProfile: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return projects
|
||||
|
|
@ -137,6 +154,28 @@ func (gui *Gui) getSelectedProjectName() string {
|
|||
return project.Name
|
||||
}
|
||||
|
||||
// isSelectedProjectAProfile reports whether the currently selected project-panel
|
||||
// row is a profile pseudo-project. Used to disambiguate selection restore when
|
||||
// a profile name happens to equal a project name.
|
||||
func (gui *Gui) isSelectedProjectAProfile() bool {
|
||||
project, err := gui.Panels.Projects.GetSelectedItem()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return project.IsProfile
|
||||
}
|
||||
|
||||
// getSelectedProfile returns the selected profile name, or "" if the current
|
||||
// project-panel selection isn't a profile. Used by the services and containers
|
||||
// filters to scope to a profile's service set.
|
||||
func (gui *Gui) getSelectedProfile() string {
|
||||
project, err := gui.Panels.Projects.GetSelectedItem()
|
||||
if err != nil || project == nil || !project.IsProfile {
|
||||
return ""
|
||||
}
|
||||
return project.Name
|
||||
}
|
||||
|
||||
func (gui *Gui) renderCredits(_project *commands.Project) tasks.TaskFunc {
|
||||
return gui.NewSimpleRenderStringTask(func() string { return gui.creditsStr() })
|
||||
}
|
||||
|
|
@ -165,10 +204,16 @@ func (gui *Gui) renderAllLogs(project *commands.Project) tasks.TaskFunc {
|
|||
Func: func(ctx context.Context) {
|
||||
gui.clearMainView()
|
||||
|
||||
obj := commands.CommandObject{}
|
||||
if project != nil && project.IsProfile {
|
||||
obj.Profile = project.Name
|
||||
} else {
|
||||
obj.Project = project
|
||||
}
|
||||
cmd := gui.OSCommand.RunCustomCommand(
|
||||
utils.ApplyTemplate(
|
||||
gui.Config.UserConfig.CommandTemplates.AllLogs,
|
||||
gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project}),
|
||||
gui.DockerCommand.NewCommandObject(obj),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -196,6 +241,11 @@ func (gui *Gui) renderDockerComposeConfig(project *commands.Project) tasks.TaskF
|
|||
return "Compose config is only available when launched from a docker-compose project directory"
|
||||
})
|
||||
}
|
||||
if project != nil && project.IsProfile {
|
||||
return gui.NewSimpleRenderStringTask(func() string {
|
||||
return utils.ColoredYamlString(gui.DockerCommand.DockerComposeConfigForProfile(project.Name))
|
||||
})
|
||||
}
|
||||
if project != nil && project.Name != gui.DockerCommand.LocalProjectName {
|
||||
return gui.NewSimpleRenderStringTask(func() string {
|
||||
return "Compose config is not available for non-local projects"
|
||||
|
|
@ -230,7 +280,13 @@ func lazydockerTitle() string {
|
|||
// handleViewAllLogs switches to a subprocess viewing all the logs from docker-compose
|
||||
func (gui *Gui) handleViewAllLogs(g *gocui.Gui, v *gocui.View) error {
|
||||
project, _ := gui.Panels.Projects.GetSelectedItem()
|
||||
c, err := gui.DockerCommand.ViewAllLogs(project)
|
||||
obj := commands.CommandObject{}
|
||||
if project != nil && project.IsProfile {
|
||||
obj.Profile = project.Name
|
||||
} else {
|
||||
obj.Project = project
|
||||
}
|
||||
c, err := gui.DockerCommand.ViewAllLogs(obj)
|
||||
if err != nil {
|
||||
return gui.createErrorPanel(err.Error())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,22 @@ func (gui *Gui) getServicesPanel() *panels.SideListPanel[*commands.Service] {
|
|||
return a.Name < b.Name
|
||||
},
|
||||
Filter: func(service *commands.Service) bool {
|
||||
// Profile selected? scope to the local project AND to services
|
||||
// declared in (or default-active for) that profile. Per docker
|
||||
// compose semantics, `--profile X config --services` returns
|
||||
// profile-X services plus default (no-profile) services.
|
||||
if profile := gui.getSelectedProfile(); profile != "" {
|
||||
if service.ProjectName != gui.DockerCommand.LocalProjectName {
|
||||
return false
|
||||
}
|
||||
svcs, _ := gui.DockerCommand.GetProfileServices(profile)
|
||||
for _, n := range svcs {
|
||||
if n == service.Name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
selectedProject := gui.getSelectedProjectName()
|
||||
if selectedProject == "" {
|
||||
// Before any project is selected (e.g. startup), default to
|
||||
|
|
@ -335,16 +351,26 @@ func (gui *Gui) handleServiceRenderLogsToMain(g *gocui.Gui, v *gocui.View) error
|
|||
|
||||
func (gui *Gui) handleProjectUp(g *gocui.Gui, v *gocui.View) error {
|
||||
project, _ := gui.Panels.Projects.GetSelectedItem()
|
||||
if project != nil && project.Name != gui.DockerCommand.LocalProjectName {
|
||||
if project != nil && !project.IsProfile && project.Name != gui.DockerCommand.LocalProjectName {
|
||||
return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService)
|
||||
}
|
||||
return gui.createConfirmationPanel(gui.Tr.Confirm, gui.Tr.ConfirmUpProject, func(g *gocui.Gui, v *gocui.View) error {
|
||||
obj := commands.CommandObject{}
|
||||
confirmMsg := gui.Tr.ConfirmUpProject
|
||||
status := gui.Tr.UppingProjectStatus
|
||||
if project != nil && project.IsProfile {
|
||||
obj.Profile = project.Name
|
||||
confirmMsg = gui.Tr.ConfirmUpProfile
|
||||
status = gui.Tr.UppingProfileStatus
|
||||
} else {
|
||||
obj.Project = project
|
||||
}
|
||||
return gui.createConfirmationPanel(gui.Tr.Confirm, confirmMsg, func(g *gocui.Gui, v *gocui.View) error {
|
||||
cmdStr := utils.ApplyTemplate(
|
||||
gui.Config.UserConfig.CommandTemplates.Up,
|
||||
gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project}),
|
||||
gui.DockerCommand.NewCommandObject(obj),
|
||||
)
|
||||
|
||||
return gui.WithWaitingStatus(gui.Tr.UppingProjectStatus, func() error {
|
||||
return gui.WithWaitingStatus(status, func() error {
|
||||
if err := gui.OSCommand.RunCommand(cmdStr); err != nil {
|
||||
return gui.createErrorPanel(err.Error())
|
||||
}
|
||||
|
|
@ -355,17 +381,25 @@ func (gui *Gui) handleProjectUp(g *gocui.Gui, v *gocui.View) error {
|
|||
|
||||
func (gui *Gui) handleProjectDown(g *gocui.Gui, v *gocui.View) error {
|
||||
project, _ := gui.Panels.Projects.GetSelectedItem()
|
||||
if project != nil && project.Name != gui.DockerCommand.LocalProjectName {
|
||||
if project != nil && !project.IsProfile && project.Name != gui.DockerCommand.LocalProjectName {
|
||||
return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService)
|
||||
}
|
||||
obj := commands.CommandObject{}
|
||||
status := gui.Tr.DowningStatus
|
||||
if project != nil && project.IsProfile {
|
||||
obj.Profile = project.Name
|
||||
status = gui.Tr.DowningProfileStatus
|
||||
} else {
|
||||
obj.Project = project
|
||||
}
|
||||
downCommand := utils.ApplyTemplate(
|
||||
gui.Config.UserConfig.CommandTemplates.Down,
|
||||
gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project}),
|
||||
gui.DockerCommand.NewCommandObject(obj),
|
||||
)
|
||||
|
||||
downWithVolumesCommand := utils.ApplyTemplate(
|
||||
gui.Config.UserConfig.CommandTemplates.DownWithVolumes,
|
||||
gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project}),
|
||||
gui.DockerCommand.NewCommandObject(obj),
|
||||
)
|
||||
|
||||
options := []*commandOption{
|
||||
|
|
@ -373,7 +407,7 @@ func (gui *Gui) handleProjectDown(g *gocui.Gui, v *gocui.View) error {
|
|||
description: gui.Tr.Down,
|
||||
command: downCommand,
|
||||
onPress: func() error {
|
||||
return gui.WithWaitingStatus(gui.Tr.DowningStatus, func() error {
|
||||
return gui.WithWaitingStatus(status, func() error {
|
||||
if err := gui.OSCommand.RunCommand(downCommand); err != nil {
|
||||
return gui.createErrorPanel(err.Error())
|
||||
}
|
||||
|
|
@ -385,7 +419,7 @@ func (gui *Gui) handleProjectDown(g *gocui.Gui, v *gocui.View) error {
|
|||
description: gui.Tr.DownWithVolumes,
|
||||
command: downWithVolumesCommand,
|
||||
onPress: func() error {
|
||||
return gui.WithWaitingStatus(gui.Tr.DowningStatus, func() error {
|
||||
return gui.WithWaitingStatus(status, func() error {
|
||||
if err := gui.OSCommand.RunCommand(downWithVolumesCommand); err != nil {
|
||||
return gui.createErrorPanel(err.Error())
|
||||
}
|
||||
|
|
@ -408,6 +442,34 @@ func (gui *Gui) handleProjectDown(g *gocui.Gui, v *gocui.View) error {
|
|||
})
|
||||
}
|
||||
|
||||
// handleProjectRestart restarts the selected project (or the selected
|
||||
// profile's services within the local project). Not currently bound to a key
|
||||
// in keybindings.go — opt-in via custom-command config or a follow-up PR.
|
||||
func (gui *Gui) handleProjectRestart(g *gocui.Gui, v *gocui.View) error {
|
||||
project, _ := gui.Panels.Projects.GetSelectedItem()
|
||||
if project != nil && !project.IsProfile && project.Name != gui.DockerCommand.LocalProjectName {
|
||||
return gui.createErrorPanel(gui.Tr.CannotManageNonLocalService)
|
||||
}
|
||||
obj := commands.CommandObject{}
|
||||
status := gui.Tr.RestartingStatus
|
||||
if project != nil && project.IsProfile {
|
||||
obj.Profile = project.Name
|
||||
status = gui.Tr.RestartingProfileStatus
|
||||
} else {
|
||||
obj.Project = project
|
||||
}
|
||||
cmdStr := utils.ApplyTemplate(
|
||||
gui.Config.UserConfig.CommandTemplates.Restart,
|
||||
gui.DockerCommand.NewCommandObject(obj),
|
||||
)
|
||||
return gui.WithWaitingStatus(status, func() error {
|
||||
if err := gui.OSCommand.RunCommand(cmdStr); err != nil {
|
||||
return gui.createErrorPanel(err.Error())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (gui *Gui) handleServiceRestartMenu(g *gocui.Gui, v *gocui.View) error {
|
||||
service, err := gui.Panels.Services.GetSelectedItem()
|
||||
if err != nil {
|
||||
|
|
@ -524,7 +586,15 @@ L:
|
|||
func (gui *Gui) handleServicesBulkCommand(g *gocui.Gui, v *gocui.View) error {
|
||||
project, _ := gui.Panels.Projects.GetSelectedItem()
|
||||
bulkCommands := gui.Config.UserConfig.BulkCommands.Services
|
||||
commandObject := gui.DockerCommand.NewCommandObject(commands.CommandObject{Project: project})
|
||||
obj := commands.CommandObject{}
|
||||
if project != nil && project.IsProfile {
|
||||
// Profile selected: user-defined bulk commands that use
|
||||
// {{ .DockerCompose }} get --profile X auto-injected.
|
||||
obj.Profile = project.Name
|
||||
} else {
|
||||
obj.Project = project
|
||||
}
|
||||
commandObject := gui.DockerCommand.NewCommandObject(obj)
|
||||
|
||||
return gui.createBulkCommandMenu(bulkCommands, commandObject)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,21 @@ type TranslationSet struct {
|
|||
ViewLogs string
|
||||
UpProject string
|
||||
DownProject string
|
||||
RestartProject string
|
||||
|
||||
// Profile-related strings for docker-compose profile pseudo-projects in
|
||||
// the project panel. Selecting a profile and triggering Up/Down/Restart
|
||||
// shells out with `--profile <name>` instead of bare project commands.
|
||||
ProfileLabel string
|
||||
UpProfile string
|
||||
DownProfile string
|
||||
RestartProfile string
|
||||
ConfirmUpProfile string
|
||||
ConfirmDownProfile string
|
||||
ConfirmRestartProfile string
|
||||
UppingProfileStatus string
|
||||
DowningProfileStatus string
|
||||
RestartingProfileStatus string
|
||||
ServicesTitle string
|
||||
ContainersTitle string
|
||||
StandaloneContainersTitle string
|
||||
|
|
@ -198,6 +213,14 @@ func englishSet() TranslationSet {
|
|||
ViewLogs: "view logs",
|
||||
UpProject: "up project",
|
||||
DownProject: "down project",
|
||||
RestartProject: "restart project",
|
||||
ProfileLabel: "profile",
|
||||
UpProfile: "up profile",
|
||||
DownProfile: "down profile",
|
||||
RestartProfile: "restart profile",
|
||||
UppingProfileStatus: "upping profile",
|
||||
DowningProfileStatus: "downing profile",
|
||||
RestartingProfileStatus: "restarting profile",
|
||||
RemoveImage: "remove image",
|
||||
RemoveVolume: "remove volume",
|
||||
RemoveNetwork: "remove network",
|
||||
|
|
@ -253,6 +276,9 @@ func englishSet() TranslationSet {
|
|||
|
||||
ConfirmQuit: "Are you sure you want to quit?",
|
||||
ConfirmUpProject: "Are you sure you want to 'up' your docker compose project?",
|
||||
ConfirmUpProfile: "Are you sure you want to 'up' this docker compose profile?",
|
||||
ConfirmDownProfile: "Are you sure you want to 'down' this docker compose profile?",
|
||||
ConfirmRestartProfile: "Are you sure you want to 'restart' this docker compose profile?",
|
||||
MustForceToRemoveContainer: "You cannot remove a running container unless you force it. Do you want to force it?",
|
||||
NotEnoughSpace: "Not enough space to render panels",
|
||||
ConfirmPruneImages: "Are you sure you want to prune all unused images?",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue