mirror of https://github.com/amir20/dozzle.git
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:
parent
a7d6a5088a
commit
872729a93b
34
README.md
34
README.md
|
@ -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
|
||||
|
||||
|
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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']>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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}`,
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
const sessionHost = useSessionStorage("host", "localhost");
|
||||
|
||||
export { sessionHost };
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -10,7 +10,8 @@
|
|||
"version": "{{ .Version }}",
|
||||
"authorizationNeeded": "{{ .AuthorizationNeeded }}",
|
||||
"secured": "{{ .Secured }}",
|
||||
"hostname": "{{ .Hostname }}"
|
||||
"hostname": "{{ .Hostname }}",
|
||||
"hosts": "{{ .Hosts }}"
|
||||
}
|
||||
</script>
|
||||
<link
|
||||
|
|
40
main.go
40
main.go
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
10
web/logs.go
10
web/logs.go
|
@ -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")
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue