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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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`.
### With Docker swarm
### Connecting to remote hosts
docker service create \
--name=dozzle \
--publish=8888:8080 \
--constraint=node.role==manager \
--mount=type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock \
amir20/dozzle:latest
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.
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.
Below are examples of using `--remote-host` via CLI:
$ 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
@ -129,6 +148,7 @@ Dozzle follows the [12-factor](https://12factor.net/) model. Configurations can
| `--usernamefile` | `DOZZLE_USERNAME_FILE` | `""` |
| `--passwordfile` | `DOZZLE_PASSWORD_FILE` | `""` |
| `--no-analytics` | `DOZZLE_NO_ANALYTICS` | false |
| `--remote-host` | `DOZZLE_REMOTE_HOST` | |
## Troubleshooting and FAQs

View File

@ -1,17 +1,18 @@
package analytics
type StartEvent struct {
ClientId string `json:"-"`
Version string `json:"version"`
FilterLength int `json:"filterLength"`
CustomAddress bool `json:"customAddress"`
CustomBase bool `json:"customBase"`
Protected bool `json:"protected"`
HasHostname bool `json:"hasHostname"`
ClientId string `json:"-"`
Version string `json:"version"`
FilterLength int `json:"filterLength"`
RemoteHostLength int `json:"remoteHostLength"`
CustomAddress bool `json:"customAddress"`
CustomBase bool `json:"customBase"`
Protected bool `json:"protected"`
HasHostname bool `json:"hasHostname"`
}
type RequestEvent struct {
ClientId string `json:"-"`
TotalContainers int `json:"totalContainers"`
ClientId string `json:"-"`
TotalContainers int `json:"totalContainers"`
RunningContainers int `json:"runningContainers"`
}

View File

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

View File

@ -12,7 +12,7 @@
</div>
</div>
</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-left">
<div class="level-item">

View File

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

View File

@ -8,10 +8,27 @@
<use href="#logo"></use>
</svg>
</router-link>
<small class="subtitle is-6 is-block mb-4" v-if="hostname">
{{ hostname }}
</small>
</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 class="columns is-marginless">
@ -71,6 +88,7 @@
<script lang="ts" setup>
import { Container } from "@/models/Container";
import { sessionHost } from "@/composables/storage";
const { base, secured, hostname } = config;
const store = useContainerStore();
@ -122,6 +140,7 @@ li.exited a {
&:hover .column-icon {
visibility: visible;
&:hover {
color: var(--secondary-color);
}

View File

@ -58,7 +58,9 @@ export function useLogStream(container: ComputedRef<Container>) {
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?.close();
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";
maxLogs: number;
hostname: string;
hosts: string[] | string;
}
const pageConfig = JSON.parse(text);
@ -22,10 +23,12 @@ if (config.version == "{{ .Version }}") {
config.authorizationNeeded = false;
config.secured = false;
config.hostname = "localhost";
config.hosts = ["localhost"];
} else {
config.version = config.version.replace(/^v/, "");
config.authorizationNeeded = config.authorizationNeeded === "true";
config.secured = config.secured === "true";
config.hosts = (config.hosts as string).split(",");
}
export default config as Config;

View File

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

View File

@ -5,6 +5,9 @@ import (
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
@ -14,6 +17,7 @@ import (
"github.com/docker/docker/api/types/events"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
log "github.com/sirupsen/logrus"
)
@ -62,6 +66,49 @@ func NewClientWithFilters(f map[string][]string) Client {
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) {
var container Container
containers, err := d.ListContainers()

View File

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

40
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."`
Filter map[string][]string `arg:"-"`
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 {
@ -91,6 +92,7 @@ func main() {
}
log.Infof("Dozzle version %s", version)
dockerClient := docker.NewClientWithFilters(args.Filter)
for i := 1; ; i++ {
_, 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 {
args.Username = args.UsernameFile.Value
}
@ -120,12 +131,12 @@ func main() {
}
config := web.Config{
Addr: args.Addr,
Base: args.Base,
Version: version,
Username: args.Username,
Password: args.Password,
Hostname: args.Hostname,
Addr: args.Addr,
Base: args.Base,
Version: version,
Username: args.Username,
Password: args.Password,
Hostname: args.Hostname,
NoAnalytics: args.NoAnalytics,
}
@ -139,7 +150,7 @@ func main() {
assets = os.DirFS("./dist")
}
srv := web.CreateServer(dockerClient, assets, config)
srv := web.CreateServer(clients, assets, config)
go doStartEvent(args)
go func() {
log.Infof("Accepting connections on %s", srv.Addr)
@ -171,13 +182,14 @@ func doStartEvent(arg args) {
}
event := analytics.StartEvent{
ClientId: host,
Version: version,
FilterLength: len(arg.Filter),
CustomAddress: arg.Addr != ":8080",
CustomBase: arg.Base != "/",
Protected: arg.Username != "",
HasHostname: arg.Hostname != "",
ClientId: host,
Version: version,
FilterLength: len(arg.Filter),
CustomAddress: arg.Addr != ":8080",
CustomBase: arg.Base != "/",
RemoteHostLength: len(arg.RemoteHost),
Protected: arg.Username != "",
HasHostname: arg.Hostname != "",
}
if err := analytics.SendStartEvent(event); err != nil {

View File

@ -1,7 +1,9 @@
package web
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
@ -25,25 +27,27 @@ func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
events, err := h.client.Events(ctx)
client := h.clientFromRequest(r)
events, err := client.Events(ctx)
stats := make(chan docker.ContainerStat)
if containers, err := h.client.ListContainers(); err == nil {
for _, c := range containers {
if c.State == "running" {
if err := h.client.ContainerStats(ctx, c.ID, stats); err != nil {
log.Errorf("error while streaming container stats: %v", err)
}
}
}
}
if err := sendContainersJSON(h.client, w); 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 {
if c.State == "running" {
if err := client.ContainerStats(ctx, c.ID, stats); err != nil && !errors.Is(err, context.Canceled) {
log.Errorf("error while streaming container stats: %v", err)
}
}
}
}()
}
for {
select {
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)
if event.Name == "start" {
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)
}
if err := sendContainersJSON(h.client, w); err != nil {
if err := sendContainersJSON(client, w); err != nil {
log.Errorf("error encoding containers to stream: %v", err)
return
}

View File

@ -21,7 +21,7 @@ import (
func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
container, err := h.client.FindContainer(id)
container, err := h.clientFromRequest(r).FindContainer(id)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@ -38,7 +38,7 @@ func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
zw.Comment = "Logs generated by Dozzle"
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 {
http.Error(w, err.Error(), http.StatusInternalServerError)
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"))
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()
if err != nil {
@ -89,7 +89,7 @@ func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) {
return
}
container, err := h.client.FindContainer(id)
container, err := h.clientFromRequest(r).FindContainer(id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
@ -106,7 +106,7 @@ func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) {
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 == io.EOF {
fmt.Fprintf(w, "event: container-stopped\ndata: end of stream\n\n")

View File

@ -8,6 +8,7 @@ import (
"net/http"
"os"
"path"
"strings"
"github.com/amir20/dozzle/analytics"
"github.com/amir20/dozzle/docker"
@ -28,15 +29,15 @@ type Config struct {
}
type handler struct {
client docker.Client
clients map[string]docker.Client
content fs.FS
config *Config
}
// 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{
client: c,
clients: clients,
content: content,
config: &config,
}
@ -85,7 +86,7 @@ func (h *handler) index(w http.ResponseWriter, req *http.Request) {
go func() {
host, _ := os.Hostname()
if containers, err := h.client.ListContainers(); err == nil {
if containers, err := h.clients["localhost"].ListContainers(); err == nil {
totalContainers := len(containers)
runningContainers := 0
for _, container := range containers {
@ -93,6 +94,7 @@ func (h *handler) index(w http.ResponseWriter, req *http.Request) {
runningContainers++
}
}
re := analytics.RequestEvent{
ClientId: host,
TotalContainers: totalContainers,
@ -130,18 +132,26 @@ 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)
}
data := struct {
Base string
Version string
AuthorizationNeeded bool
Secured bool
Hostname string
Hosts string
}{
path,
h.config.Version,
h.isAuthorizationNeeded(req),
secured,
h.config.Hostname,
strings.Join(hosts, ","),
}
err = tmpl.Execute(w, data)
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) {
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)
http.Error(w, err.Error(), http.StatusInternalServerError)
} else {
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("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)
rr := httptest.NewRecorder()
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("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)
rr := httptest.NewRecorder()
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("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)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
@ -91,7 +100,10 @@ func Test_handler_streamLogs_error_finding_container(t *testing.T) {
mockedClient := new(MockedClient)
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)
rr := httptest.NewRecorder()
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("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)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
@ -140,7 +155,10 @@ func Test_handler_streamEvents_happy(t *testing.T) {
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)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
@ -162,7 +180,10 @@ func Test_handler_streamEvents_error(t *testing.T) {
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)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
@ -188,7 +209,10 @@ func Test_handler_streamEvents_error_request(t *testing.T) {
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)
rr := httptest.NewRecorder()
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"))
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)
rr := httptest.NewRecorder()
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)
}
clients := map[string]docker.Client{
"localhost": client,
}
return createRouter(&handler{
client: client,
clients: clients,
content: content,
config: &config,
})