1
0
mirror of https://github.com/amir20/dozzle.git synced 2025-12-21 21:33:18 +01:00

feat: adds ability to support labels with | delimeter (#2276)

* feat: adds ability to support labels with | delimeter

* fixes tests

* updates docs
This commit is contained in:
Amir Raminfar
2023-06-28 12:14:17 -07:00
committed by GitHub
parent f6807db592
commit 70acaa64d8
15 changed files with 203 additions and 95 deletions

View File

@@ -1 +1 @@
-r '\.(go)$' -R 'node_modules' -G '*_test.go' -s -- go run main.go --level debug --remote-host tcp://64.225.88.189:2376
-r '\.(go)$' -R 'node_modules' -G '*_test.go' -s -- go run main.go --level debug --remote-host tcp://64.225.88.189:2376|clashleaders.com

View File

@@ -3,7 +3,7 @@
<nav class="breadcrumb menu-label" aria-label="breadcrumbs">
<ul v-if="sessionHost">
<li>
<a href="#" @click.prevent="setHost(null)">{{ sessionHost }}</a>
<a href="#" @click.prevent="setHost(null)">{{ hosts[sessionHost].name }}</a>
</li>
<li class="is-active">
<a href="#" aria-current="page">{{ $t("label.containers") }}</a>
@@ -16,7 +16,7 @@
<transition :name="sessionHost ? 'slide-left' : 'slide-right'" mode="out-in">
<ul class="menu-list" v-if="!sessionHost">
<li v-for="host in config.hosts">
<a @click.prevent="setHost(host)">{{ host }}</a>
<a @click.prevent="setHost(host.host)">{{ host.name }}</a>
</li>
</ul>
<ul class="menu-list" v-else>
@@ -85,6 +85,13 @@ const sortedContainers = computed(() =>
})
);
const hosts = computed(() =>
config.hosts.reduce((acc, item) => {
acc[item.host] = item;
return acc;
}, {} as Record<string, { name: string; host: string }>)
);
const activeContainersById = computed(() =>
activeContainers.value.reduce((acc, item) => {
acc[item.id] = item;
@@ -101,7 +108,8 @@ const activeContainersById = computed(() =>
opacity: 0.5;
}
li.exited a, li.dead a {
li.exited a,
li.dead a {
color: #777;
}

View File

@@ -26,15 +26,15 @@
<o-dropdown v-model="sessionHost" aria-role="list">
<template #trigger>
<o-button variant="primary" type="button" size="small">
<span>{{ sessionHost }}</span>
<span>{{ sessionHost ? hosts[sessionHost].name : "" }}</span>
<span class="icon">
<carbon:caret-down />
</span>
</o-button>
</template>
<o-dropdown-item :value="value" aria-role="listitem" v-for="value in config.hosts" :key="value">
<span>{{ value }}</span>
<o-dropdown-item :value="value.host" aria-role="listitem" v-for="value in config.hosts" :key="value">
<span>{{ value.name }}</span>
</o-dropdown-item>
</o-dropdown>
</div>
@@ -111,6 +111,13 @@ const sortedContainers = computed(() =>
}
})
);
const hosts = computed(() =>
config.hosts.reduce((acc, item) => {
acc[item.host] = item;
return acc;
}, {} as Record<string, { name: string; host: string }>)
);
</script>
<style scoped lang="scss">
aside {

View File

@@ -3,7 +3,7 @@ import { Container } from "@/models/Container";
const sessionHost = useSessionStorage<string | null>("host", null);
if (config.hosts.length === 1 && !sessionHost.value) {
sessionHost.value = config.hosts[0];
sessionHost.value = config.hosts[0].host;
}
function persistentVisibleKeys(container: ComputedRef<Container>) {

View File

@@ -7,7 +7,7 @@ interface Config {
secured: boolean;
maxLogs: number;
hostname: string;
hosts: string[];
hosts: { name: string; host: string }[];
}
const pageConfig = JSON.parse(text);

View File

@@ -5,8 +5,6 @@ import (
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
@@ -25,7 +23,7 @@ import (
type dockerClient struct {
cli dockerProxy
filters filters.Args
host string
host *Host
}
type StdType int
@@ -69,7 +67,7 @@ type Client interface {
ContainerLogsBetweenDates(context.Context, string, time.Time, time.Time, StdType) (io.ReadCloser, error)
ContainerStats(context.Context, string, chan<- ContainerStat) error
Ping(context.Context) (types.Ping, error)
Host() string
Host() *Host
}
// NewClientWithFilters creates a new instance of Client with docker filters
@@ -89,10 +87,10 @@ func NewClientWithFilters(f map[string][]string) (Client, error) {
return nil, err
}
return &dockerClient{cli, filterArgs, "localhost"}, nil
return &dockerClient{cli, filterArgs, &Host{Name: "localhost", Host: "localhost"}}, nil
}
func NewClientWithTlsAndFilter(f map[string][]string, connection string) (Client, error) {
func NewClientWithTlsAndFilter(f map[string][]string, host Host) (Client, error) {
filterArgs := filters.NewArgs()
for key, values := range f {
for _, value := range values {
@@ -102,38 +100,19 @@ func NewClientWithTlsAndFilter(f map[string][]string, connection string) (Client
log.Debugf("filterArgs = %v", filterArgs)
remoteUrl, err := url.Parse(connection)
if err != nil {
return nil, err
}
if remoteUrl.Scheme != "tcp" {
if host.URL.Scheme != "tcp" {
log.Fatal("Only tcp scheme is supported")
}
host := remoteUrl.Hostname()
basePath, err := filepath.Abs("./certs")
if err != nil {
log.Fatalf("error converting certs path to absolute: %s", err)
}
if _, err := os.Stat(filepath.Join(basePath, host)); !os.IsNotExist(err) {
basePath = filepath.Join(basePath, host)
}
cacertPath := filepath.Join(basePath, "ca.pem")
certPath := filepath.Join(basePath, "cert.pem")
keyPath := filepath.Join(basePath, "key.pem")
opts := []client.Opt{
client.WithHost(connection),
client.WithHost(host.URL.String()),
}
if _, err := os.Stat(cacertPath); os.IsNotExist(err) {
log.Debugf("%s does not exist, using plain HTTP", cacertPath)
if host.ValidCerts {
log.Debugf("Using TLS client config with certs at: %s", filepath.Dir(host.CertPath))
opts = append(opts, client.WithTLSClientConfig(host.CACertPath, host.CertPath, host.KeyPath))
} else {
log.Debugf("Using TLS client config with certs at: %s", basePath)
opts = append(opts, client.WithTLSClientConfig(cacertPath, certPath, keyPath))
log.Debugf("No valid certs found, using plain TCP")
}
opts = append(opts, client.WithAPIVersionNegotiation())
@@ -144,7 +123,7 @@ func NewClientWithTlsAndFilter(f map[string][]string, connection string) (Client
return nil, err
}
return &dockerClient{cli, filterArgs, host}, nil
return &dockerClient{cli, filterArgs, &host}, nil
}
func (d *dockerClient) FindContainer(id string) (Container, error) {
@@ -191,7 +170,7 @@ func (d *dockerClient) ListContainers() ([]Container, error) {
Created: c.Created,
State: c.State,
Status: c.Status,
Host: d.host,
Host: d.host.Host,
Health: findBetweenParentheses(c.Status),
}
containers = append(containers, container)
@@ -308,7 +287,7 @@ func (d *dockerClient) Events(ctx context.Context, messages chan<- ContainerEven
messages <- ContainerEvent{
ActorID: message.Actor.ID[:12],
Name: message.Action,
Host: d.host,
Host: d.host.Host,
}
}
}
@@ -371,7 +350,7 @@ func (d *dockerClient) Ping(ctx context.Context) (types.Ping, error) {
return d.cli.Ping(ctx)
}
func (d *dockerClient) Host() string {
func (d *dockerClient) Host() *Host {
return d.host
}

View File

@@ -54,7 +54,7 @@ func (m *mockedProxy) ContainerStats(ctx context.Context, containerID string, st
func Test_dockerClient_ListContainers_null(t *testing.T) {
proxy := new(mockedProxy)
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(nil, nil)
client := &dockerClient{proxy, filters.NewArgs(), "localhost"}
client := &dockerClient{proxy, filters.NewArgs(), &Host{Host: "localhost"}}
list, err := client.ListContainers()
assert.Empty(t, list, "list should be empty")
@@ -66,7 +66,7 @@ func Test_dockerClient_ListContainers_null(t *testing.T) {
func Test_dockerClient_ListContainers_error(t *testing.T) {
proxy := new(mockedProxy)
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(nil, errors.New("test"))
client := &dockerClient{proxy, filters.NewArgs(), "localhost"}
client := &dockerClient{proxy, filters.NewArgs(), &Host{Host: "localhost"}}
list, err := client.ListContainers()
assert.Nil(t, list, "list should be nil")
@@ -89,7 +89,7 @@ func Test_dockerClient_ListContainers_happy(t *testing.T) {
proxy := new(mockedProxy)
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil)
client := &dockerClient{proxy, filters.NewArgs(), "localhost"}
client := &dockerClient{proxy, filters.NewArgs(), &Host{Host: "localhost"}}
list, err := client.ListContainers()
require.NoError(t, err, "error should not return an error.")
@@ -129,7 +129,7 @@ func Test_dockerClient_ContainerLogs_happy(t *testing.T) {
json := types.ContainerJSON{Config: &container.Config{Tty: false}}
proxy.On("ContainerInspect", mock.Anything, id).Return(json, nil)
client := &dockerClient{proxy, filters.NewArgs(), "localhost"}
client := &dockerClient{proxy, filters.NewArgs(), &Host{Host: "localhost"}}
logReader, _ := client.ContainerLogs(context.Background(), id, "since", STDALL)
actual, _ := io.ReadAll(logReader)
@@ -150,7 +150,7 @@ func Test_dockerClient_ContainerLogs_happy_with_tty(t *testing.T) {
json := types.ContainerJSON{Config: &container.Config{Tty: true}}
proxy.On("ContainerInspect", mock.Anything, id).Return(json, nil)
client := &dockerClient{proxy, filters.NewArgs(), "localhost"}
client := &dockerClient{proxy, filters.NewArgs(), &Host{Host: "localhost"}}
logReader, _ := client.ContainerLogs(context.Background(), id, "", STDALL)
actual, _ := io.ReadAll(logReader)
@@ -165,7 +165,7 @@ func Test_dockerClient_ContainerLogs_error(t *testing.T) {
proxy.On("ContainerLogs", mock.Anything, id, mock.Anything).Return(nil, errors.New("test"))
client := &dockerClient{proxy, filters.NewArgs(), "localhost"}
client := &dockerClient{proxy, filters.NewArgs(), &Host{Host: "localhost"}}
reader, err := client.ContainerLogs(context.Background(), id, "", STDALL)
@@ -188,7 +188,7 @@ func Test_dockerClient_FindContainer_happy(t *testing.T) {
proxy := new(mockedProxy)
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil)
client := &dockerClient{proxy, filters.NewArgs(), "localhost"}
client := &dockerClient{proxy, filters.NewArgs(), &Host{Host: "localhost"}}
container, err := client.FindContainer("abcdefghijkl")
require.NoError(t, err, "error should not be thrown")
@@ -216,7 +216,7 @@ func Test_dockerClient_FindContainer_error(t *testing.T) {
proxy := new(mockedProxy)
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil)
client := &dockerClient{proxy, filters.NewArgs(), "localhost"}
client := &dockerClient{proxy, filters.NewArgs(), &Host{Host: "localhost"}}
_, err := client.FindContainer("not_valid")
require.Error(t, err, "error should be thrown")

68
docker/host.go Normal file
View File

@@ -0,0 +1,68 @@
package docker
import (
"fmt"
"log"
"net/url"
"os"
"path/filepath"
"strings"
)
type Host struct {
Name string `json:"name"`
Host string `json:"host"`
URL *url.URL `json:"-"`
CertPath string `json:"-"`
CACertPath string `json:"-"`
KeyPath string `json:"-"`
ValidCerts bool `json:"-"`
}
func ParseConnection(connection string) (Host, error) {
parts := strings.Split(connection, "|")
if len(parts) > 2 {
return Host{}, fmt.Errorf("invalid connection string: %s", connection)
}
remoteUrl, err := url.Parse(parts[0])
if err != nil {
return Host{}, err
}
name := remoteUrl.Hostname()
if len(parts) == 2 {
name = parts[1]
}
basePath, err := filepath.Abs("./certs")
if err != nil {
log.Fatalf("error converting certs path to absolute: %s", err)
}
host := remoteUrl.Hostname()
if _, err := os.Stat(filepath.Join(basePath, host)); !os.IsNotExist(err) {
basePath = filepath.Join(basePath, host)
}
cacertPath := filepath.Join(basePath, "ca.pem")
certPath := filepath.Join(basePath, "cert.pem")
keyPath := filepath.Join(basePath, "key.pem")
hasCerts := true
if _, err := os.Stat(cacertPath); os.IsNotExist(err) {
cacertPath = ""
hasCerts = false
}
return Host{
Name: name,
Host: host,
URL: remoteUrl,
CertPath: certPath,
CACertPath: cacertPath,
KeyPath: keyPath,
ValidCerts: hasCerts,
}, nil
}

View File

@@ -3,11 +3,9 @@
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
export {}
declare module '@vue/runtime-core' {
declare module 'vue' {
export interface GlobalComponents {
BrowserWindow: typeof import('./.vitepress/theme/components/BrowserWindow.vue')['default']
HeroVideo: typeof import('./.vitepress/theme/components/HeroVideo.vue')['default']

View File

@@ -53,3 +53,29 @@ docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir2
::: warning
Exposing `docker.sock` publicly is not safe. Only use a proxy for an internal network where all clients are trusted.
:::
## Adding labels to hosts
`--remote-host` supports host labels by appending them to the connection string with `|`. For example, `--remote-host tcp://123.1.1.1:2375|foobar.com` will use foobar.com as the label in the UI. A full example of this using the CLI or compose are:
::: code-group
```sh
docker run --volume=/var/run/docker.sock:/var/run/docker.sock -p 8080:8080 amir20/dozzle --remote-host tcp://123.1.1.1:2375|foobar.com
```
```yaml [docker-compose.yml]
version: "3"
services:
dozzle:
image: amir20/dozzle:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /path/to/certs:/certs
ports:
- 8080:8080
environment:
DOZZLE_REMOTE_HOST: tcp://167.99.1.1:2376|foo.com,tcp://167.99.1.2:2376|bar.com
```
:::

19
main.go
View File

@@ -137,24 +137,29 @@ func doStartEvent(arg args) {
}
}
func createClients(args args, localClientFactory func(map[string][]string) (docker.Client, error), remoteClientFactory func(map[string][]string, string) (docker.Client, error)) map[string]docker.Client {
func createClients(args args, localClientFactory func(map[string][]string) (docker.Client, error), remoteClientFactory func(map[string][]string, docker.Host) (docker.Client, error)) map[string]docker.Client {
clients := make(map[string]docker.Client)
if localClient := createLocalClient(args, localClientFactory); localClient != nil {
clients[localClient.Host()] = localClient
clients[localClient.Host().Host] = localClient
}
for _, host := range args.RemoteHost {
log.Infof("Creating client for %s", host)
for _, remoteHost := range args.RemoteHost {
host, err := docker.ParseConnection(remoteHost)
if err != nil {
log.Fatalf("Could not parse remote host %s: %s", remoteHost, err)
}
log.Debugf("Creating remote client for %s with %+v", host.Name, host)
log.Infof("Creating client for %s with %s", host.Name, host.URL.String())
if client, err := remoteClientFactory(args.Filter, host); err == nil {
if _, err := client.ListContainers(); err == nil {
log.Debugf("Connected to local Docker Engine")
clients[client.Host()] = client
clients[client.Host().Host] = client
} else {
log.Warnf("Could not connect to remote host %s: %s", host, err)
log.Warnf("Could not connect to remote host %s: %s", host.Host, err)
}
} else {
log.Warnf("Could not create client for %s: %s", host, err)
log.Warnf("Could not create client for %s: %s", host.Host, err)
}
}

View File

@@ -19,16 +19,18 @@ func (f *fakeClient) ListContainers() ([]docker.Container, error) {
return args.Get(0).([]docker.Container), args.Error(1)
}
func (f *fakeClient) Host() string {
func (f *fakeClient) Host() *docker.Host {
args := f.Called()
return args.String(0)
return args.Get(0).(*docker.Host)
}
func Test_valid_localhost(t *testing.T) {
fakeClientFactory := func(filter map[string][]string) (docker.Client, error) {
client := new(fakeClient)
client.On("ListContainers").Return([]docker.Container{}, nil)
client.On("Host").Return("localhost")
client.On("Host").Return(&docker.Host{
Host: "localhost",
})
return client, nil
}
@@ -43,7 +45,9 @@ func Test_invalid_localhost(t *testing.T) {
fakeClientFactory := func(filter map[string][]string) (docker.Client, error) {
client := new(fakeClient)
client.On("ListContainers").Return([]docker.Container{}, errors.New("error"))
client.On("Host").Return("localhost")
client.On("Host").Return(&docker.Host{
Host: "localhost",
})
return client, nil
}
@@ -58,15 +62,19 @@ func Test_valid_remote(t *testing.T) {
fakeLocalClientFactory := func(filter map[string][]string) (docker.Client, error) {
client := new(fakeClient)
client.On("ListContainers").Return([]docker.Container{}, errors.New("error"))
client.On("Host").Return("localhost")
client.On("Host").Return(&docker.Host{
Host: "localhost",
})
return client, nil
}
fakeRemoteClientFactory := func(filter map[string][]string, host string) (docker.Client, error) {
fakeRemoteClientFactory := func(filter map[string][]string, host docker.Host) (docker.Client, error) {
client := new(fakeClient)
client.On("ListContainers").Return([]docker.Container{}, nil)
client.On("Host").Return("test")
client.On("Host").Return(&docker.Host{
Host: "test",
})
return client, nil
}
@@ -85,14 +93,18 @@ func Test_valid_remote_and_local(t *testing.T) {
fakeLocalClientFactory := func(filter map[string][]string) (docker.Client, error) {
client := new(fakeClient)
client.On("ListContainers").Return([]docker.Container{}, nil)
client.On("Host").Return("localhost")
client.On("Host").Return(&docker.Host{
Host: "localhost",
})
return client, nil
}
fakeRemoteClientFactory := func(filter map[string][]string, host string) (docker.Client, error) {
fakeRemoteClientFactory := func(filter map[string][]string, host docker.Host) (docker.Client, error) {
client := new(fakeClient)
client.On("ListContainers").Return([]docker.Container{}, nil)
client.On("Host").Return("test")
client.On("Host").Return(&docker.Host{
Host: "test",
})
return client, nil
}
@@ -111,13 +123,17 @@ func Test_no_clients(t *testing.T) {
fakeLocalClientFactory := func(filter map[string][]string) (docker.Client, error) {
client := new(fakeClient)
client.On("ListContainers").Return([]docker.Container{}, errors.New("error"))
client.On("Host").Return("localhost")
client.On("Host").Return(&docker.Host{
Host: "localhost",
})
return client, nil
}
fakeRemoteClientFactory := func(filter map[string][]string, host string) (docker.Client, error) {
fakeRemoteClientFactory := func(filter map[string][]string, host docker.Host) (docker.Client, error) {
client := new(fakeClient)
client.On("Host").Return("test")
client.On("Host").Return(&docker.Host{
Host: "test",
})
return client, nil
}

View File

@@ -6,6 +6,7 @@ import (
"html/template"
"io"
"io/fs"
"sort"
"net/http"
"os"
@@ -154,26 +155,21 @@ func (h *handler) executeTemplate(w http.ResponseWriter, req *http.Request) {
path = h.config.Base
}
// Get all keys from hosts map
hosts := make([]string, 0, len(h.clients))
for k := range h.clients {
hosts = append(hosts, k)
hosts := make([]*docker.Host, 0, len(h.clients))
for _, v := range h.clients {
hosts = append(hosts, v.Host())
}
sort.Slice(hosts, func(i, j int) bool {
return hosts[i].Name < hosts[j].Name
})
config := struct {
Base string `json:"base"`
Version string `json:"version"`
AuthorizationNeeded bool `json:"authorizationNeeded"`
Secured bool `json:"secured"`
Hostname string `json:"hostname"`
Hosts []string `json:"hosts"`
}{
path,
h.config.Version,
h.isAuthorizationNeeded(req),
secured,
h.config.Hostname,
hosts,
config := map[string]interface{}{
"base": path,
"version": h.config.Version,
"authorizationNeeded": h.isAuthorizationNeeded(req),
"secured": secured,
"hostname": h.config.Hostname,
"hosts": hosts,
}
data := map[string]interface{}{

View File

@@ -26,6 +26,7 @@ import (
func Test_createRoutes_index(t *testing.T) {
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, "index.html", []byte("index page"), 0644), "WriteFile should have no error.")
handler := createHandler(nil, afero.NewIOFS(fs), Config{Base: "/"})
req, err := http.NewRequest("GET", "/", nil)
require.NoError(t, err, "NewRequest should not return an error.")
@@ -64,6 +65,7 @@ func Test_createRoutes_redirect_with_auth(t *testing.T) {
func Test_createRoutes_foobar(t *testing.T) {
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, "index.html", []byte("foo page"), 0644), "WriteFile should have no error.")
handler := createHandler(nil, afero.NewIOFS(fs), Config{Base: "/foobar"})
req, err := http.NewRequest("GET", "/foobar/", nil)
require.NoError(t, err, "NewRequest should not return an error.")

View File

@@ -49,15 +49,18 @@ func (m *MockedClient) ContainerLogsBetweenDates(ctx context.Context, id string,
return args.Get(0).(io.ReadCloser), args.Error(1)
}
func (m *MockedClient) Host() string {
func (m *MockedClient) Host() *docker.Host {
args := m.Called()
return args.String(0)
return args.Get(0).(*docker.Host)
}
func createHandler(client docker.Client, content fs.FS, config Config) *chi.Mux {
if client == nil {
client = new(MockedClient)
client.(*MockedClient).On("ListContainers").Return([]docker.Container{}, nil)
client.(*MockedClient).On("Host").Return(&docker.Host{
Host: "localhost",
})
}
if content == nil {