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

Adds support for multiple hosts (#2059)

* Adds support for multiple hosts

* Adds UI for drop down

* Adds support for TLS and remove SSH

* Changes dropdown to only show up with >1 hosts

* Fixes js tests

* Fixes go tests

* Fixes download link

* Updates readme

* Removes unused imports

* Fixes spaces
This commit is contained in:
Amir Raminfar
2023-02-24 09:42:58 -08:00
committed by GitHub
parent a7d6a5088a
commit 872729a93b
18 changed files with 285 additions and 109 deletions

View File

@@ -37,14 +37,33 @@ The simplest way to use dozzle is to run the docker container. Also, mount the D
Dozzle will be available at [http://localhost:8888/](http://localhost:8888/). You can change `-p 8888:8080` to any port. For example, if you want to view dozzle over port 4040 then you would do `-p 4040:8080`. Dozzle will be available at [http://localhost:8888/](http://localhost:8888/). You can change `-p 8888:8080` to any port. For example, if you want to view dozzle over port 4040 then you would do `-p 4040:8080`.
### With Docker swarm ### Connecting to remote hosts
docker service create \ Dozzle supports connecting to multiple remote hosts via `tcp://` using TLS or without. Appropriate certs need to be mounted for Dozzle to be able to successfully connect. At this point, `ssh://` is not supported because Dozzle docker image does not ship with any ssh clients.
--name=dozzle \
--publish=8888:8080 \ To configure remote hosts, `--remote-host` or `DOZZLE_REMOTE_HOST` need to provided and the `pem` files need to be mounted to `/cert` directory. The `/cert` directory expects to have `/certs/{ca,cert,key}.pem` or `/certs/{host}/{ca,cert,key}.pem` in case of multiple hosts.
--constraint=node.role==manager \
--mount=type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock \ Below are examples of using `--remote-host` via CLI:
amir20/dozzle:latest
$ docker run -v /var/run/docker.sock:/var/run/docker.sock -v /path/to/certs:/certs -p 8080:8080 amir20/dozzle --remote-host tcp://167.99.1.1:2376
Multiple `--remote-host` flags can be used to specify multiple hosts.
Or to use compose:
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,tcp://167.99.1.2:2376
You need to make sure appropriate certs are provided in `/certs/167.99.1.1/{ca,cert,key}.pem` and `/certs/167.99.1.2/{ca,cert,key}.pem` for both hosts to work.
### With Docker compose ### With Docker compose
@@ -129,6 +148,7 @@ Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can
| `--usernamefile` | `DOZZLE_USERNAME_FILE` | `""` | | `--usernamefile` | `DOZZLE_USERNAME_FILE` | `""` |
| `--passwordfile` | `DOZZLE_PASSWORD_FILE` | `""` | | `--passwordfile` | `DOZZLE_PASSWORD_FILE` | `""` |
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | false | | `--no-analytics` | `DOZZLE_NO_ANALYTICS` | false |
| `--remote-host` | `DOZZLE_REMOTE_HOST` | |
## Troubleshooting and FAQs ## Troubleshooting and FAQs

View File

@@ -4,6 +4,7 @@ type StartEvent struct {
ClientId string `json:"-"` ClientId string `json:"-"`
Version string `json:"version"` Version string `json:"version"`
FilterLength int `json:"filterLength"` FilterLength int `json:"filterLength"`
RemoteHostLength int `json:"remoteHostLength"`
CustomAddress bool `json:"customAddress"` CustomAddress bool `json:"customAddress"`
CustomBase bool `json:"customBase"` CustomBase bool `json:"customBase"`
Protected bool `json:"protected"` Protected bool `json:"protected"`

View File

@@ -109,6 +109,7 @@ declare global {
const resolveRef: typeof import('@vueuse/core')['resolveRef'] const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref'] const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const search: typeof import('./composables/settings')['search'] const search: typeof import('./composables/settings')['search']
const sessionHost: typeof import('./composables/storage')['sessionHost']
const setActivePinia: typeof import('pinia')['setActivePinia'] const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const setTitle: typeof import('./composables/title')['setTitle'] const setTitle: typeof import('./composables/title')['setTitle']
@@ -431,6 +432,7 @@ declare module 'vue' {
readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']> readonly resolveRef: UnwrapRef<typeof import('@vueuse/core')['resolveRef']>
readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']> readonly resolveUnref: UnwrapRef<typeof import('@vueuse/core')['resolveUnref']>
readonly search: UnwrapRef<typeof import('./composables/settings')['search']> readonly search: UnwrapRef<typeof import('./composables/settings')['search']>
readonly sessionHost: UnwrapRef<typeof import('./composables/storage')['sessionHost']>
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']> readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']> readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
readonly setTitle: UnwrapRef<typeof import('./composables/title')['setTitle']> readonly setTitle: UnwrapRef<typeof import('./composables/title')['setTitle']>

View File

@@ -12,7 +12,7 @@
</div> </div>
</div> </div>
</a> </a>
<a class="dropdown-item" :href="`${base}/api/logs/download?id=${container.id}`"> <a class="dropdown-item" :href="`${base}/api/logs/download?id=${container.id}&host=${sessionHost}`">
<div class="level is-justify-content-start"> <div class="level is-justify-content-start">
<div class="level-left"> <div class="level-left">
<div class="level-item"> <div class="level-item">

View File

@@ -48,7 +48,7 @@ describe("<LogEventSource />", () => {
) { ) {
settings.value.hourStyle = hourStyle; settings.value.hourStyle = hourStyle;
search.searchFilter.value = searchFilter; search.searchFilter.value = searchFilter;
if(searchFilter){ if (searchFilter) {
search.showSearch.value = true; search.showSearch.value = true;
} }
@@ -91,22 +91,22 @@ describe("<LogEventSource />", () => {
test("should connect to EventSource", async () => { test("should connect to EventSource", async () => {
const wrapper = createLogEventSource(); const wrapper = createLogEventSource();
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitOpen();
expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(1); expect(sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].readyState).toBe(1);
wrapper.unmount(); wrapper.unmount();
}); });
test("should close EventSource", async () => { test("should close EventSource", async () => {
const wrapper = createLogEventSource(); const wrapper = createLogEventSource();
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitOpen();
wrapper.unmount(); wrapper.unmount();
expect(sources["/api/logs/stream?id=abc&lastEventId="].readyState).toBe(2); expect(sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].readyState).toBe(2);
}); });
test("should parse messages", async () => { test("should parse messages", async () => {
const wrapper = createLogEventSource(); const wrapper = createLogEventSource();
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitOpen();
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitMessage({
data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`, data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`,
}); });
@@ -121,8 +121,8 @@ describe("<LogEventSource />", () => {
describe("render html correctly", () => { describe("render html correctly", () => {
test("should render messages", async () => { test("should render messages", async () => {
const wrapper = createLogEventSource(); const wrapper = createLogEventSource();
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitOpen();
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitMessage({
data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`, data: `{"ts":1560336942459, "m":"This is a message.", "id":1}`,
}); });
@@ -134,8 +134,8 @@ describe("<LogEventSource />", () => {
test("should render messages with color", async () => { test("should render messages with color", async () => {
const wrapper = createLogEventSource(); const wrapper = createLogEventSource();
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitOpen();
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitMessage({
data: '{"ts":1560336942459,"m":"\\u001b[30mblack\\u001b[37mwhite", "id":1}', data: '{"ts":1560336942459,"m":"\\u001b[30mblack\\u001b[37mwhite", "id":1}',
}); });
@@ -147,8 +147,8 @@ describe("<LogEventSource />", () => {
test("should render messages with html entities", async () => { test("should render messages with html entities", async () => {
const wrapper = createLogEventSource(); const wrapper = createLogEventSource();
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitOpen();
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitMessage({
data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`, data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`,
}); });
@@ -160,8 +160,8 @@ describe("<LogEventSource />", () => {
test("should render dates with 12 hour style", async () => { test("should render dates with 12 hour style", async () => {
const wrapper = createLogEventSource({ hourStyle: "12" }); const wrapper = createLogEventSource({ hourStyle: "12" });
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitOpen();
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitMessage({
data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`, data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`,
}); });
@@ -173,8 +173,8 @@ describe("<LogEventSource />", () => {
test("should render dates with 24 hour style", async () => { test("should render dates with 24 hour style", async () => {
const wrapper = createLogEventSource({ hourStyle: "24" }); const wrapper = createLogEventSource({ hourStyle: "24" });
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitOpen();
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitMessage({
data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`, data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`,
}); });
@@ -186,11 +186,11 @@ describe("<LogEventSource />", () => {
test("should render messages with filter", async () => { test("should render messages with filter", async () => {
const wrapper = createLogEventSource({ searchFilter: "test" }); const wrapper = createLogEventSource({ searchFilter: "test" });
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen(); sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitOpen();
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitMessage({
data: `{"ts":1560336942459, "m":"foo bar", "id":1}`, data: `{"ts":1560336942459, "m":"foo bar", "id":1}`,
}); });
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ sources["/api/logs/stream?id=abc&lastEventId=&host=localhost"].emitMessage({
data: `{"ts":1560336942459, "m":"test bar", "id":2}`, data: `{"ts":1560336942459, "m":"test bar", "id":2}`,
}); });

View File

@@ -8,10 +8,27 @@
<use href="#logo"></use> <use href="#logo"></use>
</svg> </svg>
</router-link> </router-link>
<small class="subtitle is-6 is-block mb-4" v-if="hostname"> <small class="subtitle is-6 is-block mb-4" v-if="hostname">
{{ hostname }} {{ hostname }}
</small> </small>
</h1> </h1>
<div v-if="config.hosts.length > 1" class="mb-3">
<o-dropdown v-model="sessionHost" aria-role="list">
<template #trigger>
<o-button variant="primary" type="button" size="small">
<span>{{ sessionHost }}</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>
</o-dropdown>
</div>
</div> </div>
</div> </div>
<div class="columns is-marginless"> <div class="columns is-marginless">
@@ -71,6 +88,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { Container } from "@/models/Container"; import { Container } from "@/models/Container";
import { sessionHost } from "@/composables/storage";
const { base, secured, hostname } = config; const { base, secured, hostname } = config;
const store = useContainerStore(); const store = useContainerStore();
@@ -122,6 +140,7 @@ li.exited a {
&:hover .column-icon { &:hover .column-icon {
visibility: visible; visibility: visible;
&:hover { &:hover {
color: var(--secondary-color); color: var(--secondary-color);
} }

View File

@@ -58,7 +58,9 @@ export function useLogStream(container: ComputedRef<Container>) {
lastEventId = ""; lastEventId = "";
} }
es = new EventSource(`${config.base}/api/logs/stream?id=${container.value.id}&lastEventId=${lastEventId}`); es = new EventSource(
`${config.base}/api/logs/stream?id=${container.value.id}&lastEventId=${lastEventId}&host=${sessionHost.value}`
);
es.addEventListener("container-stopped", () => { es.addEventListener("container-stopped", () => {
es?.close(); es?.close();
es = null; es = null;

View File

@@ -0,0 +1,3 @@
const sessionHost = useSessionStorage("host", "localhost");
export { sessionHost };

View File

@@ -7,6 +7,7 @@ interface Config {
secured: boolean | "false" | "true"; secured: boolean | "false" | "true";
maxLogs: number; maxLogs: number;
hostname: string; hostname: string;
hosts: string[] | string;
} }
const pageConfig = JSON.parse(text); const pageConfig = JSON.parse(text);
@@ -22,10 +23,12 @@ if (config.version == "{{ .Version }}") {
config.authorizationNeeded = false; config.authorizationNeeded = false;
config.secured = false; config.secured = false;
config.hostname = "localhost"; config.hostname = "localhost";
config.hosts = ["localhost"];
} else { } else {
config.version = config.version.replace(/^v/, ""); config.version = config.version.replace(/^v/, "");
config.authorizationNeeded = config.authorizationNeeded === "true"; config.authorizationNeeded = config.authorizationNeeded === "true";
config.secured = config.secured === "true"; config.secured = config.secured === "true";
config.hosts = (config.hosts as string).split(",");
} }
export default config as Config; export default config as Config;

View File

@@ -6,6 +6,8 @@ import { Container } from "@/models/Container";
export const useContainerStore = defineStore("container", () => { export const useContainerStore = defineStore("container", () => {
const containers: Ref<Container[]> = ref([]); const containers: Ref<Container[]> = ref([]);
const activeContainerIds: Ref<string[]> = ref([]); const activeContainerIds: Ref<string[]> = ref([]);
let es: EventSource | null = null;
const ready = ref(false);
const allContainersById = computed(() => const allContainersById = computed(() =>
containers.value.reduce((acc, container) => { containers.value.reduce((acc, container) => {
@@ -21,7 +23,19 @@ export const useContainerStore = defineStore("container", () => {
const activeContainers = computed(() => activeContainerIds.value.map((id) => allContainersById.value[id])); const activeContainers = computed(() => activeContainerIds.value.map((id) => allContainersById.value[id]));
const es = new EventSource(`${config.base}/api/events/stream`); watch(
sessionHost,
() => {
connect();
},
{ immediate: true }
);
function connect() {
es?.close();
ready.value = false;
es = new EventSource(`${config.base}/api/events/stream?host=${sessionHost.value}`);
es.addEventListener("containers-changed", (e: Event) => es.addEventListener("containers-changed", (e: Event) =>
setContainers(JSON.parse((e as MessageEvent).data) as ContainerJson[]) setContainers(JSON.parse((e as MessageEvent).data) as ContainerJson[])
); );
@@ -41,6 +55,9 @@ export const useContainerStore = defineStore("container", () => {
} }
}); });
watchOnce(containers, () => (ready.value = true));
}
const setContainers = (newContainers: ContainerJson[]) => { const setContainers = (newContainers: ContainerJson[]) => {
containers.value = newContainers.map((c) => { containers.value = newContainers.map((c) => {
const existing = allContainersById.value[c.id]; const existing = allContainersById.value[c.id];
@@ -58,9 +75,6 @@ export const useContainerStore = defineStore("container", () => {
const removeActiveContainer = ({ id }: Container) => const removeActiveContainer = ({ id }: Container) =>
activeContainerIds.value.splice(activeContainerIds.value.indexOf(id), 1); activeContainerIds.value.splice(activeContainerIds.value.indexOf(id), 1);
const ready = ref(false);
watchOnce(containers, () => (ready.value = true));
return { return {
containers, containers,
activeContainerIds, activeContainerIds,

View File

@@ -5,6 +5,9 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/url"
"os"
"path/filepath"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -14,6 +17,7 @@ import (
"github.com/docker/docker/api/types/events" "github.com/docker/docker/api/types/events"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client" "github.com/docker/docker/client"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -62,6 +66,49 @@ func NewClientWithFilters(f map[string][]string) Client {
return &dockerClient{cli, filterArgs} return &dockerClient{cli, filterArgs}
} }
func NewClientWithTlsAndFilter(f map[string][]string, connection string) Client {
filterArgs := filters.NewArgs()
for key, values := range f {
for _, value := range values {
filterArgs.Add(key, value)
}
}
log.Debugf("filterArgs = %v", filterArgs)
remoteUrl, err := url.Parse(connection)
if err != nil {
log.Fatal(err)
}
if remoteUrl.Scheme != "tcp" {
log.Fatal("Only tcp scheme is supported")
}
host := remoteUrl.Hostname()
basePath := "/certs"
if _, err := os.Stat(filepath.Join(basePath, host)); os.IsExist(err) {
basePath = filepath.Join(basePath, host)
}
cacertPath := filepath.Join(basePath, "ca.pem")
certPath := filepath.Join(basePath, "cert.pem")
keyPath := filepath.Join(basePath, "key.pem")
cli, err := client.NewClientWithOpts(
client.WithHost(connection),
client.WithTLSClientConfig(cacertPath, certPath, keyPath),
client.WithAPIVersionNegotiation(),
)
if err != nil {
log.Fatal(err)
}
return &dockerClient{cli, filterArgs}
}
func (d *dockerClient) FindContainer(id string) (Container, error) { func (d *dockerClient) FindContainer(id string) (Container, error) {
var container Container var container Container
containers, err := d.ListContainers() containers, err := d.ListContainers()

View File

@@ -10,7 +10,8 @@
"version": "{{ .Version }}", "version": "{{ .Version }}",
"authorizationNeeded": "{{ .AuthorizationNeeded }}", "authorizationNeeded": "{{ .AuthorizationNeeded }}",
"secured": "{{ .Secured }}", "secured": "{{ .Secured }}",
"hostname": "{{ .Hostname }}" "hostname": "{{ .Hostname }}",
"hosts": "{{ .Hosts }}"
} }
</script> </script>
<link <link

14
main.go
View File

@@ -48,6 +48,7 @@ type args struct {
FilterStrings []string `arg:"env:DOZZLE_FILTER,--filter,separate" help:"filters docker containers using Docker syntax."` FilterStrings []string `arg:"env:DOZZLE_FILTER,--filter,separate" help:"filters docker containers using Docker syntax."`
Filter map[string][]string `arg:"-"` Filter map[string][]string `arg:"-"`
Healthcheck *HealthcheckCmd `arg:"subcommand:healthcheck" help:"checks if the server is running."` Healthcheck *HealthcheckCmd `arg:"subcommand:healthcheck" help:"checks if the server is running."`
RemoteHost []string `arg:"env:DOZZLE_REMOTE_HOST,--remote-host,separate" help:"list of hosts to connect remotely"`
} }
type HealthcheckCmd struct { type HealthcheckCmd struct {
@@ -91,6 +92,7 @@ func main() {
} }
log.Infof("Dozzle version %s", version) log.Infof("Dozzle version %s", version)
dockerClient := docker.NewClientWithFilters(args.Filter) dockerClient := docker.NewClientWithFilters(args.Filter)
for i := 1; ; i++ { for i := 1; ; i++ {
_, err := dockerClient.ListContainers() _, err := dockerClient.ListContainers()
@@ -105,6 +107,15 @@ func main() {
} }
} }
clients := make(map[string]docker.Client)
clients["localhost"] = dockerClient
for _, host := range args.RemoteHost {
log.Infof("Creating a client for %s", host)
client := docker.NewClientWithTlsAndFilter(args.Filter, host)
clients[host] = client
}
if args.Username == "" && args.UsernameFile != nil { if args.Username == "" && args.UsernameFile != nil {
args.Username = args.UsernameFile.Value args.Username = args.UsernameFile.Value
} }
@@ -139,7 +150,7 @@ func main() {
assets = os.DirFS("./dist") assets = os.DirFS("./dist")
} }
srv := web.CreateServer(dockerClient, assets, config) srv := web.CreateServer(clients, assets, config)
go doStartEvent(args) go doStartEvent(args)
go func() { go func() {
log.Infof("Accepting connections on %s", srv.Addr) log.Infof("Accepting connections on %s", srv.Addr)
@@ -176,6 +187,7 @@ func doStartEvent(arg args) {
FilterLength: len(arg.Filter), FilterLength: len(arg.Filter),
CustomAddress: arg.Addr != ":8080", CustomAddress: arg.Addr != ":8080",
CustomBase: arg.Base != "/", CustomBase: arg.Base != "/",
RemoteHostLength: len(arg.RemoteHost),
Protected: arg.Username != "", Protected: arg.Username != "",
HasHostname: arg.Hostname != "", HasHostname: arg.Hostname != "",
} }

View File

@@ -1,7 +1,9 @@
package web package web
import ( import (
"context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -25,25 +27,27 @@ func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
events, err := h.client.Events(ctx) client := h.clientFromRequest(r)
events, err := client.Events(ctx)
stats := make(chan docker.ContainerStat) stats := make(chan docker.ContainerStat)
if containers, err := h.client.ListContainers(); err == nil { if err := sendContainersJSON(client, w); err != nil {
log.Errorf("error while encoding containers to stream: %v", err)
}
f.Flush()
if containers, err := client.ListContainers(); err == nil {
go func() {
for _, c := range containers { for _, c := range containers {
if c.State == "running" { if c.State == "running" {
if err := h.client.ContainerStats(ctx, c.ID, stats); err != nil { if err := client.ContainerStats(ctx, c.ID, stats); err != nil && !errors.Is(err, context.Canceled) {
log.Errorf("error while streaming container stats: %v", err) log.Errorf("error while streaming container stats: %v", err)
} }
} }
} }
}()
} }
if err := sendContainersJSON(h.client, w); err != nil {
log.Errorf("error while encoding containers to stream: %v", err)
}
f.Flush()
for { for {
select { select {
case stat := <-stats: case stat := <-stats:
@@ -62,10 +66,10 @@ func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) {
log.Debugf("triggering docker event: %v", event.Name) log.Debugf("triggering docker event: %v", event.Name)
if event.Name == "start" { if event.Name == "start" {
log.Debugf("found new container with id: %v", event.ActorID) log.Debugf("found new container with id: %v", event.ActorID)
if err := h.client.ContainerStats(ctx, event.ActorID, stats); err != nil { if err := client.ContainerStats(ctx, event.ActorID, stats); err != nil && !errors.Is(err, context.Canceled) {
log.Errorf("error when streaming new container stats: %v", err) log.Errorf("error when streaming new container stats: %v", err)
} }
if err := sendContainersJSON(h.client, w); err != nil { if err := sendContainersJSON(client, w); err != nil {
log.Errorf("error encoding containers to stream: %v", err) log.Errorf("error encoding containers to stream: %v", err)
return return
} }

View File

@@ -21,7 +21,7 @@ import (
func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) { func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id") id := r.URL.Query().Get("id")
container, err := h.client.FindContainer(id) container, err := h.clientFromRequest(r).FindContainer(id)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -38,7 +38,7 @@ func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
zw.Comment = "Logs generated by Dozzle" zw.Comment = "Logs generated by Dozzle"
zw.ModTime = now zw.ModTime = now
reader, err := h.client.ContainerLogsBetweenDates(r.Context(), container.ID, from, now) reader, err := h.clientFromRequest(r).ContainerLogsBetweenDates(r.Context(), container.ID, from, now)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@@ -53,7 +53,7 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
to, _ := time.Parse(time.RFC3339, r.URL.Query().Get("to")) to, _ := time.Parse(time.RFC3339, r.URL.Query().Get("to"))
id := r.URL.Query().Get("id") id := r.URL.Query().Get("id")
reader, err := h.client.ContainerLogsBetweenDates(r.Context(), id, from, to) reader, err := h.clientFromRequest(r).ContainerLogsBetweenDates(r.Context(), id, from, to)
defer reader.Close() defer reader.Close()
if err != nil { if err != nil {
@@ -89,7 +89,7 @@ func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) {
return return
} }
container, err := h.client.FindContainer(id) container, err := h.clientFromRequest(r).FindContainer(id)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusNotFound) http.Error(w, err.Error(), http.StatusNotFound)
return return
@@ -106,7 +106,7 @@ func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) {
lastEventId = r.URL.Query().Get("lastEventId") lastEventId = r.URL.Query().Get("lastEventId")
} }
reader, err := h.client.ContainerLogs(r.Context(), container.ID, lastEventId) reader, err := h.clientFromRequest(r).ContainerLogs(r.Context(), container.ID, lastEventId)
if err != nil { if err != nil {
if err == io.EOF { if err == io.EOF {
fmt.Fprintf(w, "event: container-stopped\ndata: end of stream\n\n") fmt.Fprintf(w, "event: container-stopped\ndata: end of stream\n\n")

View File

@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"os" "os"
"path" "path"
"strings"
"github.com/amir20/dozzle/analytics" "github.com/amir20/dozzle/analytics"
"github.com/amir20/dozzle/docker" "github.com/amir20/dozzle/docker"
@@ -28,15 +29,15 @@ type Config struct {
} }
type handler struct { type handler struct {
client docker.Client clients map[string]docker.Client
content fs.FS content fs.FS
config *Config config *Config
} }
// CreateServer creates a service for http handler // CreateServer creates a service for http handler
func CreateServer(c docker.Client, content fs.FS, config Config) *http.Server { func CreateServer(clients map[string]docker.Client, content fs.FS, config Config) *http.Server {
handler := &handler{ handler := &handler{
client: c, clients: clients,
content: content, content: content,
config: &config, config: &config,
} }
@@ -85,7 +86,7 @@ func (h *handler) index(w http.ResponseWriter, req *http.Request) {
go func() { go func() {
host, _ := os.Hostname() host, _ := os.Hostname()
if containers, err := h.client.ListContainers(); err == nil { if containers, err := h.clients["localhost"].ListContainers(); err == nil {
totalContainers := len(containers) totalContainers := len(containers)
runningContainers := 0 runningContainers := 0
for _, container := range containers { for _, container := range containers {
@@ -93,6 +94,7 @@ func (h *handler) index(w http.ResponseWriter, req *http.Request) {
runningContainers++ runningContainers++
} }
} }
re := analytics.RequestEvent{ re := analytics.RequestEvent{
ClientId: host, ClientId: host,
TotalContainers: totalContainers, TotalContainers: totalContainers,
@@ -130,18 +132,26 @@ func (h *handler) executeTemplate(w http.ResponseWriter, req *http.Request) {
path = h.config.Base 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)
}
data := struct { data := struct {
Base string Base string
Version string Version string
AuthorizationNeeded bool AuthorizationNeeded bool
Secured bool Secured bool
Hostname string Hostname string
Hosts string
}{ }{
path, path,
h.config.Version, h.config.Version,
h.isAuthorizationNeeded(req), h.isAuthorizationNeeded(req),
secured, secured,
h.config.Hostname, h.config.Hostname,
strings.Join(hosts, ","),
} }
err = tmpl.Execute(w, data) err = tmpl.Execute(w, data)
if err != nil { if err != nil {
@@ -158,10 +168,18 @@ func (h *handler) version(w http.ResponseWriter, r *http.Request) {
func (h *handler) healthcheck(w http.ResponseWriter, r *http.Request) { func (h *handler) healthcheck(w http.ResponseWriter, r *http.Request) {
log.Trace("Executing healthcheck request") log.Trace("Executing healthcheck request")
if ping, err := h.client.Ping(r.Context()); err != nil { if ping, err := h.clients["localhost"].Ping(r.Context()); err != nil {
log.Error(err) log.Error(err)
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
} else { } else {
fmt.Fprintf(w, "OK API Version %v", ping.APIVersion) fmt.Fprintf(w, "OK API Version %v", ping.APIVersion)
} }
} }
func (h *handler) clientFromRequest(r *http.Request) docker.Client {
host := r.URL.Query().Get("host")
if client, ok := h.clients[host]; ok {
return client
}
return h.clients["localhost"]
}

View File

@@ -31,7 +31,10 @@ func Test_handler_streamLogs_happy(t *testing.T) {
mockedClient.On("FindContainer", id).Return(docker.Container{ID: id}, nil) mockedClient.On("FindContainer", id).Return(docker.Container{ID: id}, nil)
mockedClient.On("ContainerLogs", mock.Anything, mock.Anything, "").Return(reader, nil) mockedClient.On("ContainerLogs", mock.Anything, mock.Anything, "").Return(reader, nil)
h := handler{client: mockedClient, config: &Config{}} clients := map[string]docker.Client{
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.streamLogs) handler := http.HandlerFunc(h.streamLogs)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
@@ -52,7 +55,10 @@ func Test_handler_streamLogs_happy_with_id(t *testing.T) {
mockedClient.On("FindContainer", id).Return(docker.Container{ID: id}, nil) mockedClient.On("FindContainer", id).Return(docker.Container{ID: id}, nil)
mockedClient.On("ContainerLogs", mock.Anything, mock.Anything, "").Return(reader, nil) mockedClient.On("ContainerLogs", mock.Anything, mock.Anything, "").Return(reader, nil)
h := handler{client: mockedClient, config: &Config{}} clients := map[string]docker.Client{
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.streamLogs) handler := http.HandlerFunc(h.streamLogs)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
@@ -72,7 +78,10 @@ func Test_handler_streamLogs_happy_container_stopped(t *testing.T) {
mockedClient.On("FindContainer", id).Return(docker.Container{ID: id}, nil) mockedClient.On("FindContainer", id).Return(docker.Container{ID: id}, nil)
mockedClient.On("ContainerLogs", mock.Anything, id, "").Return(ioutil.NopCloser(strings.NewReader("")), io.EOF) mockedClient.On("ContainerLogs", mock.Anything, id, "").Return(ioutil.NopCloser(strings.NewReader("")), io.EOF)
h := handler{client: mockedClient, config: &Config{}} clients := map[string]docker.Client{
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.streamLogs) handler := http.HandlerFunc(h.streamLogs)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
@@ -91,7 +100,10 @@ func Test_handler_streamLogs_error_finding_container(t *testing.T) {
mockedClient := new(MockedClient) mockedClient := new(MockedClient)
mockedClient.On("FindContainer", id).Return(docker.Container{}, errors.New("error finding container")) mockedClient.On("FindContainer", id).Return(docker.Container{}, errors.New("error finding container"))
h := handler{client: mockedClient, config: &Config{}} clients := map[string]docker.Client{
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.streamLogs) handler := http.HandlerFunc(h.streamLogs)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
@@ -111,7 +123,10 @@ func Test_handler_streamLogs_error_reading(t *testing.T) {
mockedClient.On("FindContainer", id).Return(docker.Container{ID: id}, nil) mockedClient.On("FindContainer", id).Return(docker.Container{ID: id}, nil)
mockedClient.On("ContainerLogs", mock.Anything, id, "").Return(ioutil.NopCloser(strings.NewReader("")), errors.New("test error")) mockedClient.On("ContainerLogs", mock.Anything, id, "").Return(ioutil.NopCloser(strings.NewReader("")), errors.New("test error"))
h := handler{client: mockedClient, config: &Config{}} clients := map[string]docker.Client{
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.streamLogs) handler := http.HandlerFunc(h.streamLogs)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
@@ -140,7 +155,10 @@ func Test_handler_streamEvents_happy(t *testing.T) {
close(messages) close(messages)
}() }()
h := handler{client: mockedClient, config: &Config{}} clients := map[string]docker.Client{
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.streamEvents) handler := http.HandlerFunc(h.streamEvents)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
@@ -162,7 +180,10 @@ func Test_handler_streamEvents_error(t *testing.T) {
close(messages) close(messages)
}() }()
h := handler{client: mockedClient, config: &Config{}} clients := map[string]docker.Client{
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.streamEvents) handler := http.HandlerFunc(h.streamEvents)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
@@ -188,7 +209,10 @@ func Test_handler_streamEvents_error_request(t *testing.T) {
cancel() cancel()
}() }()
h := handler{client: mockedClient, config: &Config{}} clients := map[string]docker.Client{
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.streamEvents) handler := http.HandlerFunc(h.streamEvents)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
@@ -214,7 +238,10 @@ func Test_handler_between_dates(t *testing.T) {
reader := ioutil.NopCloser(strings.NewReader("2020-05-13T18:55:37.772853839Z INFO Testing logs...\n2020-05-13T18:55:37.772853839Z INFO Testing logs...\n")) reader := ioutil.NopCloser(strings.NewReader("2020-05-13T18:55:37.772853839Z INFO Testing logs...\n2020-05-13T18:55:37.772853839Z INFO Testing logs...\n"))
mockedClient.On("ContainerLogsBetweenDates", mock.Anything, "123456", from, to).Return(reader, nil) mockedClient.On("ContainerLogsBetweenDates", mock.Anything, "123456", from, to).Return(reader, nil)
h := handler{client: mockedClient, config: &Config{}} clients := map[string]docker.Client{
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.fetchLogsBetweenDates) handler := http.HandlerFunc(h.fetchLogsBetweenDates)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)

View File

@@ -71,8 +71,11 @@ func createHandler(client docker.Client, content fs.FS, config Config) *mux.Rout
content = afero.NewIOFS(fs) content = afero.NewIOFS(fs)
} }
clients := map[string]docker.Client{
"localhost": client,
}
return createRouter(&handler{ return createRouter(&handler{
client: client, clients: clients,
content: content, content: content,
config: &config, config: &config,
}) })