mirror of
https://github.com/jesseduffield/lazydocker.git
synced 2026-07-25 08:31:03 +00:00
support running in non-docker-compose context
This commit is contained in:
parent
87c6208577
commit
b02c92da33
6 changed files with 121 additions and 78 deletions
|
|
@ -46,13 +46,20 @@ func NewDockerCommand(log *logrus.Entry, osCommand *OSCommand, tr *i18n.Translat
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inDockerComposeProject := true
|
||||||
|
err = osCommand.RunCommand(config.UserConfig.CommandTemplates.CheckDockerComposeConfig)
|
||||||
|
if err != nil {
|
||||||
|
inDockerComposeProject = false
|
||||||
|
log.Warn(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
return &DockerCommand{
|
return &DockerCommand{
|
||||||
Log: log,
|
Log: log,
|
||||||
OSCommand: osCommand,
|
OSCommand: osCommand,
|
||||||
Tr: tr,
|
Tr: tr,
|
||||||
Config: config,
|
Config: config,
|
||||||
Client: cli,
|
Client: cli,
|
||||||
InDockerComposeProject: true, // TODO: determine this at startup
|
InDockerComposeProject: inDockerComposeProject,
|
||||||
ErrorChan: errorChan,
|
ErrorChan: errorChan,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,12 +110,13 @@ type CommandTemplatesConfig struct {
|
||||||
RebuildService string `yaml:"rebuildService,omitempty"`
|
RebuildService string `yaml:"rebuildService,omitempty"`
|
||||||
|
|
||||||
// ViewContainerLogs is for viewing the container logs in a subprocess. We have this as a separate command in case you want to show all the logs rather than just tail them for the sake of reducing CPU load when in the lazydocker GUI
|
// ViewContainerLogs is for viewing the container logs in a subprocess. We have this as a separate command in case you want to show all the logs rather than just tail them for the sake of reducing CPU load when in the lazydocker GUI
|
||||||
ViewContainerLogs string `yaml:"viewContainerLogs,omitempty"`
|
ViewContainerLogs string `yaml:"viewContainerLogs,omitempty"`
|
||||||
ContainerLogs string `yaml:"containerLogs,omitempty"`
|
ContainerLogs string `yaml:"containerLogs,omitempty"`
|
||||||
ContainerTTYLogs string `yaml:"containerTTYLogs,omitempty"`
|
ContainerTTYLogs string `yaml:"containerTTYLogs,omitempty"`
|
||||||
AllLogs string `yaml:"allLogs,omitempty"`
|
AllLogs string `yaml:"allLogs,omitempty"`
|
||||||
ViewAllLogs string `yaml:"viewAlLogs,omitempty"`
|
ViewAllLogs string `yaml:"viewAlLogs,omitempty"`
|
||||||
DockerComposeConfig string `yaml:"dockerComposeConfig,omitempty"`
|
DockerComposeConfig string `yaml:"dockerComposeConfig,omitempty"`
|
||||||
|
CheckDockerComposeConfig string `yaml:"checkDockerComposeConfig,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OSConfig struct {
|
type OSConfig struct {
|
||||||
|
|
@ -163,17 +164,18 @@ func GetDefaultConfig() UserConfig {
|
||||||
Reporting: "undetermined",
|
Reporting: "undetermined",
|
||||||
ConfirmOnQuit: false,
|
ConfirmOnQuit: false,
|
||||||
CommandTemplates: CommandTemplatesConfig{
|
CommandTemplates: CommandTemplatesConfig{
|
||||||
RestartService: "docker-compose restart {{ .Name }}",
|
RestartService: "docker-compose restart {{ .Name }}",
|
||||||
RebuildService: "docker-compose up -d --build {{ .Name }}",
|
RebuildService: "docker-compose up -d --build {{ .Name }}",
|
||||||
DockerCompose: "apdev compose",
|
DockerCompose: "docker-compose",
|
||||||
StopService: "apdev stop {{ .Name }}",
|
StopService: "docker-compose stop {{ .Name }}",
|
||||||
ServiceLogs: "apdev logs {{ .Name }}",
|
ServiceLogs: "docker-compose logs {{ .Name }}",
|
||||||
ContainerLogs: "docker logs --timestamps --follow --tail=100 {{ .ID }}",
|
AllLogs: "docker-compose logs --tail=100",
|
||||||
ViewContainerLogs: "docker logs --timestamps --follow --tail=100 {{ .ID }}",
|
ViewAllLogs: "docker-compose logs",
|
||||||
ContainerTTYLogs: "docker logs --follow --tail=100 {{ .ID }}",
|
DockerComposeConfig: "docker-compose config",
|
||||||
AllLogs: "apdev logs --tail=100",
|
CheckDockerComposeConfig: "docker-compose config --quiet",
|
||||||
ViewAllLogs: "apdev logs",
|
ContainerLogs: "docker logs --timestamps --follow --tail=100 {{ .ID }}",
|
||||||
DockerComposeConfig: "apdev compose config",
|
ViewContainerLogs: "docker logs --timestamps --follow --tail=100 {{ .ID }}",
|
||||||
|
ContainerTTYLogs: "docker logs --follow --tail=100 {{ .ID }}",
|
||||||
},
|
},
|
||||||
OS: GetPlatformDefaultConfig(),
|
OS: GetPlatformDefaultConfig(),
|
||||||
Update: UpdateConfig{
|
Update: UpdateConfig{
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ type Gui struct {
|
||||||
waitForIntro sync.WaitGroup
|
waitForIntro sync.WaitGroup
|
||||||
T *tasks.TaskManager
|
T *tasks.TaskManager
|
||||||
ErrorChan chan error
|
ErrorChan chan error
|
||||||
|
CyclableViews []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type servicePanelState struct {
|
type servicePanelState struct {
|
||||||
|
|
@ -122,9 +123,13 @@ type guiState struct {
|
||||||
|
|
||||||
// NewGui builds a new gui handler
|
// NewGui builds a new gui handler
|
||||||
func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand *commands.OSCommand, tr *i18n.TranslationSet, config *config.AppConfig, errorChan chan error) (*Gui, error) {
|
func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand *commands.OSCommand, tr *i18n.TranslationSet, config *config.AppConfig, errorChan chan error) (*Gui, error) {
|
||||||
|
previousView := "containers"
|
||||||
|
if dockerCommand.InDockerComposeProject {
|
||||||
|
previousView = "services"
|
||||||
|
}
|
||||||
|
|
||||||
initialState := guiState{
|
initialState := guiState{
|
||||||
PreviousView: "services",
|
PreviousView: previousView,
|
||||||
Platform: *oSCommand.Platform,
|
Platform: *oSCommand.Platform,
|
||||||
Panels: &panelStates{
|
Panels: &panelStates{
|
||||||
Services: &servicePanelState{SelectedLine: -1, ContextIndex: 0},
|
Services: &servicePanelState{SelectedLine: -1, ContextIndex: 0},
|
||||||
|
|
@ -139,6 +144,11 @@ func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cyclableViews := []string{"status", "containers", "images", "volumes"}
|
||||||
|
if dockerCommand.InDockerComposeProject {
|
||||||
|
cyclableViews = []string{"status", "services", "containers", "images", "volumes"}
|
||||||
|
}
|
||||||
|
|
||||||
gui := &Gui{
|
gui := &Gui{
|
||||||
Log: log,
|
Log: log,
|
||||||
DockerCommand: dockerCommand,
|
DockerCommand: dockerCommand,
|
||||||
|
|
@ -150,6 +160,7 @@ func NewGui(log *logrus.Entry, dockerCommand *commands.DockerCommand, oSCommand
|
||||||
statusManager: &statusManager{},
|
statusManager: &statusManager{},
|
||||||
T: tasks.NewTaskManager(log),
|
T: tasks.NewTaskManager(log),
|
||||||
ErrorChan: errorChan,
|
ErrorChan: errorChan,
|
||||||
|
CyclableViews: cyclableViews,
|
||||||
}
|
}
|
||||||
|
|
||||||
gui.GenerateSentinelErrors()
|
gui.GenerateSentinelErrors()
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ func (gui *Gui) layout(g *gocui.Gui) error {
|
||||||
if currView != nil {
|
if currView != nil {
|
||||||
viewName := currView.Name()
|
viewName := currView.Name()
|
||||||
usePreviouseView := true
|
usePreviouseView := true
|
||||||
for _, view := range cyclableViews {
|
for _, view := range gui.CyclableViews {
|
||||||
if view == viewName {
|
if view == viewName {
|
||||||
currentCyclebleView = viewName
|
currentCyclebleView = viewName
|
||||||
usePreviouseView = false
|
usePreviouseView = false
|
||||||
|
|
@ -92,15 +92,26 @@ func (gui *Gui) layout(g *gocui.Gui) error {
|
||||||
|
|
||||||
usableSpace := height - 4
|
usableSpace := height - 4
|
||||||
|
|
||||||
tallPanels := 4
|
tallPanels := 3
|
||||||
|
var vHeights map[string]int
|
||||||
vHeights := map[string]int{
|
if gui.DockerCommand.InDockerComposeProject {
|
||||||
"status": 3,
|
tallPanels++
|
||||||
"services": usableSpace/tallPanels + usableSpace%tallPanels,
|
vHeights = map[string]int{
|
||||||
"containers": usableSpace / tallPanels,
|
"status": 3,
|
||||||
"images": usableSpace / tallPanels,
|
"services": usableSpace/tallPanels + usableSpace%tallPanels,
|
||||||
"volumes": usableSpace / tallPanels,
|
"containers": usableSpace / tallPanels,
|
||||||
"options": 1,
|
"images": usableSpace / tallPanels,
|
||||||
|
"volumes": usableSpace / tallPanels,
|
||||||
|
"options": 1,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
vHeights = map[string]int{
|
||||||
|
"status": 3,
|
||||||
|
"containers": usableSpace/tallPanels + usableSpace%tallPanels,
|
||||||
|
"images": usableSpace / tallPanels,
|
||||||
|
"volumes": usableSpace / tallPanels,
|
||||||
|
"options": 1,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if height < 28 {
|
if height < 28 {
|
||||||
|
|
@ -110,12 +121,14 @@ func (gui *Gui) layout(g *gocui.Gui) error {
|
||||||
}
|
}
|
||||||
vHeights = map[string]int{
|
vHeights = map[string]int{
|
||||||
"status": defaultHeight,
|
"status": defaultHeight,
|
||||||
"services": defaultHeight,
|
|
||||||
"containers": defaultHeight,
|
"containers": defaultHeight,
|
||||||
"images": defaultHeight,
|
"images": defaultHeight,
|
||||||
"volumes": defaultHeight,
|
"volumes": defaultHeight,
|
||||||
"options": defaultHeight,
|
"options": defaultHeight,
|
||||||
}
|
}
|
||||||
|
if gui.DockerCommand.InDockerComposeProject {
|
||||||
|
vHeights["services"] = defaultHeight
|
||||||
|
}
|
||||||
vHeights[currentCyclebleView] = height - defaultHeight*tallPanels - 1
|
vHeights[currentCyclebleView] = height - defaultHeight*tallPanels - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -148,25 +161,30 @@ func (gui *Gui) layout(g *gocui.Gui) error {
|
||||||
v.FgColor = gocui.ColorWhite
|
v.FgColor = gocui.ColorWhite
|
||||||
}
|
}
|
||||||
|
|
||||||
servicesView, err := g.SetViewBeneath("services", "status", vHeights["services"])
|
var servicesView *gocui.View
|
||||||
if err != nil {
|
aboveContainersView := "status"
|
||||||
if err.Error() != "unknown view" {
|
if gui.DockerCommand.InDockerComposeProject {
|
||||||
return err
|
aboveContainersView = "services"
|
||||||
}
|
servicesView, err = g.SetViewBeneath("services", "status", vHeights["services"])
|
||||||
servicesView.Highlight = true
|
if err != nil {
|
||||||
servicesView.Title = gui.Tr.ServicesTitle
|
if err.Error() != "unknown view" {
|
||||||
servicesView.FgColor = gocui.ColorWhite
|
return err
|
||||||
|
}
|
||||||
|
servicesView.Highlight = true
|
||||||
|
servicesView.Title = gui.Tr.ServicesTitle
|
||||||
|
servicesView.FgColor = gocui.ColorWhite
|
||||||
|
|
||||||
gui.focusPoint(0, gui.State.Panels.Services.SelectedLine, len(gui.DockerCommand.Services), servicesView)
|
gui.focusPoint(0, gui.State.Panels.Services.SelectedLine, len(gui.DockerCommand.Services), servicesView)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
containersView, err := g.SetViewBeneath("containers", "services", vHeights["containers"])
|
containersView, err := g.SetViewBeneath("containers", aboveContainersView, vHeights["containers"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err.Error() != "unknown view" {
|
if err.Error() != "unknown view" {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
containersView.Highlight = true
|
containersView.Highlight = true
|
||||||
if gui.Config.UserConfig.Gui.ShowAllContainers {
|
if gui.Config.UserConfig.Gui.ShowAllContainers || !gui.DockerCommand.InDockerComposeProject {
|
||||||
containersView.Title = gui.Tr.ContainersTitle
|
containersView.Title = gui.Tr.ContainersTitle
|
||||||
} else {
|
} else {
|
||||||
containersView.Title = gui.Tr.StandaloneContainersTitle
|
containersView.Title = gui.Tr.StandaloneContainersTitle
|
||||||
|
|
@ -255,6 +273,7 @@ func (gui *Gui) layout(g *gocui.Gui) error {
|
||||||
lineCount int
|
lineCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: find out if I should add the services view to this.
|
||||||
listViews := map[*gocui.View]listViewState{
|
listViews := map[*gocui.View]listViewState{
|
||||||
containersView: {selectedLine: gui.State.Panels.Containers.SelectedLine, lineCount: len(gui.DockerCommand.Containers)},
|
containersView: {selectedLine: gui.State.Panels.Containers.SelectedLine, lineCount: len(gui.DockerCommand.Containers)},
|
||||||
imagesView: {selectedLine: gui.State.Panels.Images.SelectedLine, lineCount: len(gui.DockerCommand.Images)},
|
imagesView: {selectedLine: gui.State.Panels.Images.SelectedLine, lineCount: len(gui.DockerCommand.Images)},
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,17 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func (gui *Gui) getStatusContexts() []string {
|
func (gui *Gui) getStatusContexts() []string {
|
||||||
return []string{"logs", "credits", "config"}
|
if gui.DockerCommand.InDockerComposeProject {
|
||||||
|
return []string{"logs", "credits", "config"}
|
||||||
|
}
|
||||||
|
return []string{"credits"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gui *Gui) getStatusContextTitles() []string {
|
func (gui *Gui) getStatusContextTitles() []string {
|
||||||
return []string{gui.Tr.LogsTitle, gui.Tr.CreditsTitle, gui.Tr.ConfigTitle}
|
if gui.DockerCommand.InDockerComposeProject {
|
||||||
|
return []string{gui.Tr.LogsTitle, gui.Tr.CreditsTitle, gui.Tr.ConfigTitle}
|
||||||
|
}
|
||||||
|
return []string{gui.Tr.CreditsTitle}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gui *Gui) refreshStatus() error {
|
func (gui *Gui) refreshStatus() error {
|
||||||
|
|
@ -71,20 +77,20 @@ func (gui *Gui) handleStatusSelect(g *gocui.Gui, v *gocui.View) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gui *Gui) renderCredits() error {
|
func (gui *Gui) renderCredits() error {
|
||||||
mainView := gui.getMainView()
|
|
||||||
mainView.Autoscroll = false
|
|
||||||
mainView.Wrap = true
|
|
||||||
|
|
||||||
dashboardString := strings.Join(
|
|
||||||
[]string{
|
|
||||||
lazydockerTitle(),
|
|
||||||
"Copyright (c) 2019 Jesse Duffield",
|
|
||||||
"Keybindings: https://github.com/jesseduffield/lazydocker/blob/master/docs/keybindings",
|
|
||||||
"Config Options: https://github.com/jesseduffield/lazydocker/blob/master/docs/Config.md",
|
|
||||||
"Raise an Issue: https://github.com/jesseduffield/lazydocker/issues",
|
|
||||||
}, "\n\n")
|
|
||||||
|
|
||||||
go gui.T.NewTask(func(stop chan struct{}) {
|
go gui.T.NewTask(func(stop chan struct{}) {
|
||||||
|
mainView := gui.getMainView()
|
||||||
|
mainView.Autoscroll = false
|
||||||
|
mainView.Wrap = true
|
||||||
|
|
||||||
|
dashboardString := strings.Join(
|
||||||
|
[]string{
|
||||||
|
lazydockerTitle(),
|
||||||
|
"Copyright (c) 2019 Jesse Duffield",
|
||||||
|
"Keybindings: https://github.com/jesseduffield/lazydocker/blob/master/docs/keybindings",
|
||||||
|
"Config Options: https://github.com/jesseduffield/lazydocker/blob/master/docs/Config.md",
|
||||||
|
"Raise an Issue: https://github.com/jesseduffield/lazydocker/issues",
|
||||||
|
}, "\n\n")
|
||||||
|
|
||||||
gui.renderString(gui.g, "main", dashboardString)
|
gui.renderString(gui.g, "main", dashboardString)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -92,11 +98,11 @@ func (gui *Gui) renderCredits() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gui *Gui) renderAllLogs() error {
|
func (gui *Gui) renderAllLogs() error {
|
||||||
mainView := gui.getMainView()
|
|
||||||
mainView.Autoscroll = true
|
|
||||||
mainView.Wrap = true
|
|
||||||
|
|
||||||
go gui.T.NewTask(func(stop chan struct{}) {
|
go gui.T.NewTask(func(stop chan struct{}) {
|
||||||
|
mainView := gui.getMainView()
|
||||||
|
mainView.Autoscroll = true
|
||||||
|
mainView.Wrap = true
|
||||||
|
|
||||||
gui.clearMainView()
|
gui.clearMainView()
|
||||||
|
|
||||||
cmd := gui.OSCommand.RunCustomCommand(gui.Config.UserConfig.CommandTemplates.AllLogs)
|
cmd := gui.OSCommand.RunCustomCommand(gui.Config.UserConfig.CommandTemplates.AllLogs)
|
||||||
|
|
@ -121,11 +127,11 @@ func (gui *Gui) renderAllLogs() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gui *Gui) renderDockerComposeConfig() error {
|
func (gui *Gui) renderDockerComposeConfig() error {
|
||||||
mainView := gui.getMainView()
|
|
||||||
mainView.Autoscroll = false
|
|
||||||
mainView.Wrap = true
|
|
||||||
|
|
||||||
go gui.T.NewTask(func(stop chan struct{}) {
|
go gui.T.NewTask(func(stop chan struct{}) {
|
||||||
|
mainView := gui.getMainView()
|
||||||
|
mainView.Autoscroll = false
|
||||||
|
mainView.Wrap = true
|
||||||
|
|
||||||
config := gui.DockerCommand.DockerComposeConfig()
|
config := gui.DockerCommand.DockerComposeConfig()
|
||||||
gui.renderString(gui.g, "main", config)
|
gui.renderString(gui.g, "main", config)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ import (
|
||||||
"github.com/spkg/bom"
|
"github.com/spkg/bom"
|
||||||
)
|
)
|
||||||
|
|
||||||
var cyclableViews = []string{"status", "services", "containers", "images", "volumes"}
|
|
||||||
|
|
||||||
func (gui *Gui) refreshSidePanels(g *gocui.Gui) error {
|
func (gui *Gui) refreshSidePanels(g *gocui.Gui) error {
|
||||||
// not refreshing containers and services here given that we do it every few milliseconds anyway
|
// not refreshing containers and services here given that we do it every few milliseconds anyway
|
||||||
if err := gui.refreshImages(); err != nil {
|
if err := gui.refreshImages(); err != nil {
|
||||||
|
|
@ -26,16 +24,16 @@ func (gui *Gui) refreshSidePanels(g *gocui.Gui) error {
|
||||||
|
|
||||||
func (gui *Gui) nextView(g *gocui.Gui, v *gocui.View) error {
|
func (gui *Gui) nextView(g *gocui.Gui, v *gocui.View) error {
|
||||||
var focusedViewName string
|
var focusedViewName string
|
||||||
if v == nil || v.Name() == cyclableViews[len(cyclableViews)-1] {
|
if v == nil || v.Name() == gui.CyclableViews[len(gui.CyclableViews)-1] {
|
||||||
focusedViewName = cyclableViews[0]
|
focusedViewName = gui.CyclableViews[0]
|
||||||
} else {
|
} else {
|
||||||
viewName := v.Name()
|
viewName := v.Name()
|
||||||
for i := range cyclableViews {
|
for i := range gui.CyclableViews {
|
||||||
if viewName == cyclableViews[i] {
|
if viewName == gui.CyclableViews[i] {
|
||||||
focusedViewName = cyclableViews[i+1]
|
focusedViewName = gui.CyclableViews[i+1]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if i == len(cyclableViews)-1 {
|
if i == len(gui.CyclableViews)-1 {
|
||||||
gui.Log.Info("not in list of views")
|
gui.Log.Info("not in list of views")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -51,16 +49,16 @@ func (gui *Gui) nextView(g *gocui.Gui, v *gocui.View) error {
|
||||||
|
|
||||||
func (gui *Gui) previousView(g *gocui.Gui, v *gocui.View) error {
|
func (gui *Gui) previousView(g *gocui.Gui, v *gocui.View) error {
|
||||||
var focusedViewName string
|
var focusedViewName string
|
||||||
if v == nil || v.Name() == cyclableViews[0] {
|
if v == nil || v.Name() == gui.CyclableViews[0] {
|
||||||
focusedViewName = cyclableViews[len(cyclableViews)-1]
|
focusedViewName = gui.CyclableViews[len(gui.CyclableViews)-1]
|
||||||
} else {
|
} else {
|
||||||
viewName := v.Name()
|
viewName := v.Name()
|
||||||
for i := range cyclableViews {
|
for i := range gui.CyclableViews {
|
||||||
if viewName == cyclableViews[i] {
|
if viewName == gui.CyclableViews[i] {
|
||||||
focusedViewName = cyclableViews[i-1]
|
focusedViewName = gui.CyclableViews[i-1]
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if i == len(cyclableViews)-1 {
|
if i == len(gui.CyclableViews)-1 {
|
||||||
gui.Log.Info("not in list of views")
|
gui.Log.Info("not in list of views")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue