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

feat: supports multiple hosts in parallel and update hosts menu (#2269)

* feat: updates host menu to be part of side menu

* updates routes to be host/id

* fixes go tests

* fixes js tests

* fixes typescheck

* fixes int tests

* fixes mobile

* fixes bug in merging containers

* fixed minor bug with menu
This commit is contained in:
Amir Raminfar
2023-06-24 12:13:39 -07:00
committed by GitHub
parent 9f90d1ccfa
commit 14fc1190a8
38 changed files with 456 additions and 400 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,4 @@
certs
dist dist
node_modules node_modules
.cache .cache

View File

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

View File

@@ -31,4 +31,4 @@ dev:
.PHONY: int .PHONY: int
int: int:
docker compose up --force-recreate --exit-code-from playwright docker compose up --build --force-recreate --exit-code-from playwright

View File

@@ -19,6 +19,7 @@ declare module 'vue' {
ContainerPopup: typeof import('./components/LogViewer/ContainerPopup.vue')['default'] ContainerPopup: typeof import('./components/LogViewer/ContainerPopup.vue')['default']
ContainerStat: typeof import('./components/LogViewer/ContainerStat.vue')['default'] ContainerStat: typeof import('./components/LogViewer/ContainerStat.vue')['default']
ContainerTitle: typeof import('./components/LogViewer/ContainerTitle.vue')['default'] ContainerTitle: typeof import('./components/LogViewer/ContainerTitle.vue')['default']
copy: typeof import('./components/SidePanel copy.vue')['default']
DateTime: typeof import('./components/common/DateTime.vue')['default'] DateTime: typeof import('./components/common/DateTime.vue')['default']
DistanceTime: typeof import('./components/common/DistanceTime.vue')['default'] DistanceTime: typeof import('./components/common/DistanceTime.vue')['default']
DockerEventLogItem: typeof import('./components/LogViewer/DockerEventLogItem.vue')['default'] DockerEventLogItem: typeof import('./components/LogViewer/DockerEventLogItem.vue')['default']
@@ -52,6 +53,7 @@ declare module 'vue' {
ScrollProgress: typeof import('./components/ScrollProgress.vue')['default'] ScrollProgress: typeof import('./components/ScrollProgress.vue')['default']
Search: typeof import('./components/Search.vue')['default'] Search: typeof import('./components/Search.vue')['default']
SideMenu: typeof import('./components/SideMenu.vue')['default'] SideMenu: typeof import('./components/SideMenu.vue')['default']
SidePanel: typeof import('./components/SidePanel.vue')['default']
SimpleLogItem: typeof import('./components/LogViewer/SimpleLogItem.vue')['default'] SimpleLogItem: typeof import('./components/LogViewer/SimpleLogItem.vue')['default']
SkippedEntriesLogItem: typeof import('./components/LogViewer/SkippedEntriesLogItem.vue')['default'] SkippedEntriesLogItem: typeof import('./components/LogViewer/SkippedEntriesLogItem.vue')['default']
StatMonitor: typeof import('./components/LogViewer/StatMonitor.vue')['default'] StatMonitor: typeof import('./components/LogViewer/StatMonitor.vue')['default']

View File

@@ -17,9 +17,7 @@
<octicon:container-24 /> <octicon:container-24 />
</span> </span>
</div> </div>
<div class="media-content"> <div class="media-content">{{ item.host }} / {{ item.name }}</div>
{{ item.name }}
</div>
<div class="media-right"> <div class="media-right">
<span <span
class="icon is-small column-icon" class="icon is-small column-icon"
@@ -52,12 +50,13 @@ const store = useContainerStore();
const { containers } = storeToRefs(store); const { containers } = storeToRefs(store);
const list = computed(() => { const list = computed(() => {
return containers.value.map(({ id, created, name, state }) => { return containers.value.map(({ id, created, name, state, host }) => {
return { return {
id, id,
created, created,
name, name,
state, state,
host,
}; };
}); });
}); });

View File

@@ -71,7 +71,7 @@ describe("<LogEventSource />", () => {
LogViewer, LogViewer,
}, },
provide: { provide: {
container: computed(() => ({ id: "abc", image: "test:v123" })), container: computed(() => ({ id: "abc", image: "test:v123", host: "localhost" })),
"stream-config": reactive({ stdout: true, stderr: true }), "stream-config": reactive({ stdout: true, stderr: true }),
scrollingPaused: computed(() => false), scrollingPaused: computed(() => false),
}, },
@@ -85,7 +85,7 @@ describe("<LogEventSource />", () => {
}); });
} }
const sourceUrl = "/api/logs/stream?id=abc&lastEventId=&host=localhost&stdout=1&stderr=1"; const sourceUrl = "/api/logs/stream/localhost/abc?lastEventId=&stdout=1&stderr=1";
test("renders correctly", async () => { test("renders correctly", async () => {
const wrapper = createLogEventSource(); const wrapper = createLogEventSource();

View File

@@ -1,66 +1,25 @@
<template> <template>
<aside> <div v-if="ready">
<div class="columns is-marginless"> <nav class="breadcrumb menu-label" aria-label="breadcrumbs">
<div class="column is-paddingless"> <ul v-if="sessionHost">
<h1> <li>
<router-link :to="{ name: 'index' }"> <a href="#" @click.prevent="setHost(null)">{{ sessionHost }}</a>
<svg class="logo"> </li>
<use href="#logo"></use> <li class="is-active">
</svg> <a href="#" aria-current="page">{{ $t("label.containers") }}</a>
</router-link> </li>
</ul>
<small class="subtitle is-6 is-block mb-4" v-if="hostname"> <ul v-else>
{{ hostname }} <li>Hosts</li>
</small> </ul>
</h1> </nav>
<div v-if="config.hosts.length > 1" class="mb-3"> <transition :name="sessionHost ? 'slide-left' : 'slide-right'" mode="out-in">
<o-dropdown v-model="sessionHost" aria-role="list"> <ul class="menu-list" v-if="!sessionHost">
<template #trigger> <li v-for="host in config.hosts">
<o-button variant="primary" type="button" size="small"> <a @click.prevent="setHost(host)">{{ host }}</a>
<span>{{ sessionHost }}</span> </li>
<span class="icon"> </ul>
<carbon:caret-down /> <ul class="menu-list" v-else>
</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">
<div class="column is-narrow py-0 pl-0 pr-1">
<button class="button is-rounded is-small" @click="$emit('search')" :title="$t('tooltip.search')">
<span class="icon">
<mdi:light-magnify />
</span>
</button>
</div>
<div class="column is-narrow py-0" :class="secured ? 'pl-0 pr-1' : 'px-0'">
<router-link
:to="{ name: 'settings' }"
active-class="is-active"
class="button is-rounded is-small"
:aria-label="$t('title.settings')"
>
<span class="icon">
<mdi:light-cog />
</span>
</router-link>
</div>
<div class="column is-narrow py-0 px-0" v-if="secured">
<a class="button is-rounded is-small" :href="`${base}/logout`" :title="$t('button.logout')">
<span class="icon">
<mdi:light-logout />
</span>
</a>
</div>
</div>
<p class="menu-label is-hidden-mobile">{{ $t("label.containers") }}</p>
<ul class="menu-list is-hidden-mobile" v-if="ready">
<li v-for="item in sortedContainers" :key="item.id" :class="item.state"> <li v-for="item in sortedContainers" :key="item.id" :class="item.state">
<popup> <popup>
<router-link <router-link
@@ -93,23 +52,29 @@
</popup> </popup>
</li> </li>
</ul> </ul>
</transition>
</div>
<ul class="menu-list is-hidden-mobile loading" v-else> <ul class="menu-list is-hidden-mobile loading" v-else>
<li v-for="index in 7" class="my-4"><o-skeleton animated size="large" :key="index"></o-skeleton></li> <li v-for="index in 7" class="my-4"><o-skeleton animated size="large" :key="index"></o-skeleton></li>
</ul> </ul>
</aside>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { Container } from "@/models/Container"; import { Container } from "@/models/Container";
import { sessionHost } from "@/composables/storage"; import { sessionHost } from "@/composables/storage";
const { base, secured, hostname } = config;
const store = useContainerStore(); const store = useContainerStore();
const { activeContainers, visibleContainers, ready } = storeToRefs(store); const { activeContainers, visibleContainers, ready } = storeToRefs(store);
function setHost(host: string | null) {
sessionHost.value = host;
}
const sortedContainers = computed(() => const sortedContainers = computed(() =>
visibleContainers.value.sort((a, b) => { visibleContainers.value
.filter((c) => c.host === sessionHost.value)
.sort((a, b) => {
if (a.state === "running" && b.state !== "running") { if (a.state === "running" && b.state !== "running") {
return -1; return -1;
} else if (a.state !== "running" && b.state === "running") { } else if (a.state !== "running" && b.state === "running") {
@@ -128,32 +93,15 @@ const activeContainersById = computed(() =>
); );
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
aside {
padding: 1em;
height: 100vh;
overflow: auto;
position: fixed;
width: inherit;
.is-hidden-mobile.is-active {
display: block !important;
}
}
.has-light-opacity { .has-light-opacity {
opacity: 0.5; opacity: 0.5;
} }
.logo {
width: 122px;
height: 54px;
fill: var(--logo-color);
}
.loading { .loading {
opacity: 0.5; opacity: 0.5;
} }
li.exited a { li.exited a, li.dead a {
color: #777; color: #777;
} }
@@ -176,4 +124,31 @@ a {
} }
} }
} }
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.1s ease-out;
}
.slide-left-enter-from {
opacity: 0;
transform: translateX(100%);
}
.slide-right-enter-from {
opacity: 0;
transform: translateX(-100%);
}
.slide-left-leave-to {
opacity: 0;
transform: translateX(-100%);
}
.slide-right-leave-to {
opacity: 0;
transform: translateX(100%);
}
</style> </style>

View File

@@ -0,0 +1,71 @@
<template>
<aside>
<div class="columns is-marginless">
<div class="column is-paddingless">
<h1>
<router-link :to="{ name: 'index' }">
<svg class="logo">
<use href="#logo"></use>
</svg>
</router-link>
<small class="subtitle is-6 is-block mb-4" v-if="hostname">
{{ hostname }}
</small>
</h1>
</div>
</div>
<div class="columns is-marginless">
<div class="column is-narrow py-0 pl-0 pr-1">
<button class="button is-rounded is-small" @click="$emit('search')" :title="$t('tooltip.search')">
<span class="icon">
<mdi:light-magnify />
</span>
</button>
</div>
<div class="column is-narrow py-0" :class="secured ? 'pl-0 pr-1' : 'px-0'">
<router-link
:to="{ name: 'settings' }"
active-class="is-active"
class="button is-rounded is-small"
:aria-label="$t('title.settings')"
>
<span class="icon">
<mdi:light-cog />
</span>
</router-link>
</div>
<div class="column is-narrow py-0 px-0" v-if="secured">
<a class="button is-rounded is-small" :href="`${base}/logout`" :title="$t('button.logout')">
<span class="icon">
<mdi:light-logout />
</span>
</a>
</div>
</div>
<side-menu class="mt-4"></side-menu>
</aside>
</template>
<script lang="ts" setup>
const { base, secured, hostname } = config;
</script>
<style scoped lang="scss">
aside {
padding: 1em;
height: 100vh;
overflow: auto;
position: fixed;
width: inherit;
.is-hidden-mobile.is-active {
display: block !important;
}
}
.logo {
width: 122px;
height: 54px;
fill: var(--logo-color);
}
</style>

View File

@@ -70,7 +70,7 @@
<p class="menu-label is-hidden-mobile" :class="{ 'is-active': showNav }">{{ $t("label.containers") }}</p> <p class="menu-label is-hidden-mobile" :class="{ 'is-active': showNav }">{{ $t("label.containers") }}</p>
<ul class="menu-list is-hidden-mobile" :class="{ 'is-active': showNav }"> <ul class="menu-list is-hidden-mobile" :class="{ 'is-active': showNav }">
<li v-for="item in visibleContainers" :key="item.id"> <li v-for="item in sortedContainers" :key="item.id">
<router-link <router-link
:to="{ name: 'container-id', params: { id: item.id } }" :to="{ name: 'container-id', params: { id: item.id } }"
active-class="is-active" active-class="is-active"
@@ -97,6 +97,20 @@ let showNav = $ref(false);
watch(route, () => { watch(route, () => {
showNav = false; showNav = false;
}); });
const sortedContainers = computed(() =>
visibleContainers.value
.filter((c) => c.host === sessionHost.value)
.sort((a, b) => {
if (a.state === "running" && b.state !== "running") {
return -1;
} else if (a.state !== "running" && b.state === "running") {
return 1;
} else {
return a.name.localeCompare(b.name);
}
})
);
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
aside { aside {

View File

@@ -70,10 +70,8 @@ export function useLogStream(container: ComputedRef<Container>, streamConfig: Lo
} }
const params = { const params = {
id: container.value.id,
lastEventId, lastEventId,
host: sessionHost.value, } as { lastEventId: string; stdout?: string; stderr?: string };
} as { id: string; lastEventId: string; host: string; stdout?: string; stderr?: string };
if (streamConfig.stdout) { if (streamConfig.stdout) {
params.stdout = "1"; params.stdout = "1";
@@ -82,7 +80,11 @@ export function useLogStream(container: ComputedRef<Container>, streamConfig: Lo
params.stderr = "1"; params.stderr = "1";
} }
es = new EventSource(`${config.base}/api/logs/stream?${new URLSearchParams(params).toString()}`); es = new EventSource(
`${config.base}/api/logs/stream/${container.value.host}/${container.value.id}?${new URLSearchParams(
params
).toString()}`
);
es.addEventListener("container-stopped", () => { es.addEventListener("container-stopped", () => {
es?.close(); es?.close();
es = null; es = null;
@@ -111,11 +113,9 @@ export function useLogStream(container: ComputedRef<Container>, streamConfig: Lo
const from = new Date(to.getTime() + delta); const from = new Date(to.getTime() + delta);
const params = { const params = {
id: container.value.id,
from: from.toISOString(), from: from.toISOString(),
to: to.toISOString(), to: to.toISOString(),
host: sessionHost.value, } as { from: string; to: string; stdout?: string; stderr?: string };
} as { id: string; from: string; to: string; host: string; stdout?: string; stderr?: string };
if (streamConfig.stdout) { if (streamConfig.stdout) {
params.stdout = "1"; params.stdout = "1";
@@ -124,7 +124,13 @@ export function useLogStream(container: ComputedRef<Container>, streamConfig: Lo
params.stderr = "1"; params.stderr = "1";
} }
const logs = await (await fetch(`${config.base}/api/logs?${new URLSearchParams(params).toString()}`)).text(); const logs = await (
await fetch(
`${config.base}/api/logs/${container.value.host}/${container.value.id}?${new URLSearchParams(
params
).toString()}`
)
).text();
if (logs) { if (logs) {
const newMessages = logs const newMessages = logs
.trim() .trim()

View File

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

View File

@@ -3,7 +3,7 @@
<mobile-menu v-if="isMobile" @search="showFuzzySearch"></mobile-menu> <mobile-menu v-if="isMobile" @search="showFuzzySearch"></mobile-menu>
<splitpanes @resized="onResized($event)"> <splitpanes @resized="onResized($event)">
<pane min-size="10" :size="menuWidth" v-if="!isMobile && !collapseNav"> <pane min-size="10" :size="menuWidth" v-if="!isMobile && !collapseNav">
<side-menu @search="showFuzzySearch"></side-menu> <side-panel @search="showFuzzySearch"></side-panel>
</pane> </pane>
<pane min-size="10"> <pane min-size="10">
<splitpanes> <splitpanes>

View File

@@ -12,7 +12,7 @@ describe("Container", () => {
]; ];
test.each(names)("name %s should be %s and %s", (name, expectedName, expectedSwarmId) => { test.each(names)("name %s should be %s and %s", (name, expectedName, expectedSwarmId) => {
const c = new Container("id", new Date(), "image", name!, "command", "status", "created"); const c = new Container("id", new Date(), "image", name!, "command", "host", "status", "created");
expect(c.name).toBe(expectedName); expect(c.name).toBe(expectedName);
expect(c.swarmId).toBe(expectedSwarmId); expect(c.swarmId).toBe(expectedSwarmId);
}); });

View File

@@ -18,6 +18,7 @@ export class Container {
public readonly image: string, public readonly image: string,
public readonly name: string, public readonly name: string,
public readonly command: string, public readonly command: string,
public readonly host: string,
public status: string, public status: string,
public state: ContainerState, public state: ContainerState,
public health?: ContainerHealth public health?: ContainerHealth

View File

@@ -23,7 +23,7 @@ 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"]; config.hosts = ["localhost", "64.225.88.189"];
} else { } else {
config.version = config.version.replace(/^v/, ""); config.version = config.version.replace(/^v/, "");
config.authorizationNeeded = config.authorizationNeeded === "true"; config.authorizationNeeded = config.authorizationNeeded === "true";

View File

@@ -23,21 +23,13 @@ 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]));
watch(
sessionHost,
() => {
connect();
},
{ immediate: true }
);
function connect() { function connect() {
es?.close(); es?.close();
ready.value = false; ready.value = false;
es = new EventSource(`${config.base}/api/events/stream?host=${sessionHost.value}`); es = new EventSource(`${config.base}/api/events/stream`);
es.addEventListener("containers-changed", (e: Event) => es.addEventListener("containers-changed", (e: Event) =>
setContainers(JSON.parse((e as MessageEvent).data) as ContainerJson[]) updateContainers(JSON.parse((e as MessageEvent).data) as ContainerJson[])
); );
es.addEventListener("container-stat", (e) => { es.addEventListener("container-stat", (e) => {
const stat = JSON.parse((e as MessageEvent).data) as ContainerStat; const stat = JSON.parse((e as MessageEvent).data) as ContainerStat;
@@ -66,17 +58,35 @@ export const useContainerStore = defineStore("container", () => {
watchOnce(containers, () => (ready.value = true)); watchOnce(containers, () => (ready.value = true));
} }
const setContainers = (newContainers: ContainerJson[]) => { connect();
containers.value = newContainers.map((c) => {
const updateContainers = (containersPayload: ContainerJson[]) => {
const existingContainers = containersPayload.filter((c) => allContainersById.value[c.id]);
const newContainers = containersPayload.filter((c) => !allContainersById.value[c.id]);
existingContainers.forEach((c) => {
const existing = allContainersById.value[c.id]; const existing = allContainersById.value[c.id];
if (existing) {
existing.status = c.status; existing.status = c.status;
existing.state = c.state; existing.state = c.state;
existing.health = c.health; existing.health = c.health;
return existing;
}
return new Container(c.id, new Date(c.created * 1000), c.image, c.name, c.command, c.status, c.state, c.health);
}); });
containers.value = [
...containers.value,
...newContainers.map((c) => {
return new Container(
c.id,
new Date(c.created * 1000),
c.image,
c.name,
c.command,
c.host,
c.status,
c.state,
c.health
);
}),
];
}; };
const currentContainer = (id: Ref<string>) => computed(() => allContainersById.value[id.value]); const currentContainer = (id: Ref<string>) => computed(() => allContainersById.value[id.value]);

View File

@@ -13,6 +13,7 @@ export type ContainerJson = {
readonly command: string; readonly command: string;
readonly status: string; readonly status: string;
readonly state: ContainerState; readonly state: ContainerState;
readonly host: string;
readonly health?: ContainerHealth; readonly health?: ContainerHealth;
}; };

View File

@@ -25,6 +25,7 @@ import (
type dockerClient struct { type dockerClient struct {
cli dockerProxy cli dockerProxy
filters filters.Args filters filters.Args
host string
} }
type StdType int type StdType int
@@ -64,10 +65,11 @@ type Client interface {
FindContainer(string) (Container, error) FindContainer(string) (Container, error)
ContainerLogs(context.Context, string, string, StdType) (io.ReadCloser, error) ContainerLogs(context.Context, string, string, StdType) (io.ReadCloser, error)
ContainerLogReader(context.Context, string) (io.ReadCloser, error) ContainerLogReader(context.Context, string) (io.ReadCloser, error)
Events(context.Context) (<-chan ContainerEvent, <-chan error) Events(context.Context, chan<- ContainerEvent) <-chan error
ContainerLogsBetweenDates(context.Context, string, time.Time, time.Time, StdType) (io.ReadCloser, error) ContainerLogsBetweenDates(context.Context, string, time.Time, time.Time, StdType) (io.ReadCloser, error)
ContainerStats(context.Context, string, chan<- ContainerStat) error ContainerStats(context.Context, string, chan<- ContainerStat) error
Ping(context.Context) (types.Ping, error) Ping(context.Context) (types.Ping, error)
Host() string
} }
// NewClientWithFilters creates a new instance of Client with docker filters // NewClientWithFilters creates a new instance of Client with docker filters
@@ -87,7 +89,7 @@ func NewClientWithFilters(f map[string][]string) (Client, error) {
return nil, err return nil, err
} }
return &dockerClient{cli, filterArgs}, nil return &dockerClient{cli, filterArgs, "localhost"}, nil
} }
func NewClientWithTlsAndFilter(f map[string][]string, connection string) (Client, error) { func NewClientWithTlsAndFilter(f map[string][]string, connection string) (Client, error) {
@@ -110,7 +112,10 @@ func NewClientWithTlsAndFilter(f map[string][]string, connection string) (Client
} }
host := remoteUrl.Hostname() host := remoteUrl.Hostname()
basePath := "/certs" basePath, err := filepath.Abs("./certs")
if err != nil {
log.Fatalf("error converting certs path to absolute: %s", err)
}
if _, err := os.Stat(filepath.Join(basePath, host)); !os.IsNotExist(err) { if _, err := os.Stat(filepath.Join(basePath, host)); !os.IsNotExist(err) {
basePath = filepath.Join(basePath, host) basePath = filepath.Join(basePath, host)
@@ -139,7 +144,7 @@ func NewClientWithTlsAndFilter(f map[string][]string, connection string) (Client
return nil, err return nil, err
} }
return &dockerClient{cli, filterArgs}, nil return &dockerClient{cli, filterArgs, host}, nil
} }
func (d *dockerClient) FindContainer(id string) (Container, error) { func (d *dockerClient) FindContainer(id string) (Container, error) {
@@ -186,6 +191,7 @@ func (d *dockerClient) ListContainers() ([]Container, error) {
Created: c.Created, Created: c.Created,
State: c.State, State: c.State,
Status: c.Status, Status: c.Status,
Host: d.host,
Health: findBetweenParentheses(c.Status), Health: findBetweenParentheses(c.Status),
} }
containers = append(containers, container) containers = append(containers, container)
@@ -284,12 +290,10 @@ func (d *dockerClient) ContainerLogs(ctx context.Context, id string, since strin
return newLogReader(reader, containerJSON.Config.Tty, true), nil return newLogReader(reader, containerJSON.Config.Tty, true), nil
} }
func (d *dockerClient) Events(ctx context.Context) (<-chan ContainerEvent, <-chan error) { func (d *dockerClient) Events(ctx context.Context, messages chan<- ContainerEvent) <-chan error {
dockerMessages, errors := d.cli.Events(ctx, types.EventsOptions{}) dockerMessages, errors := d.cli.Events(ctx, types.EventsOptions{})
messages := make(chan ContainerEvent)
go func() { go func() {
defer close(messages)
for { for {
select { select {
@@ -304,13 +308,14 @@ func (d *dockerClient) Events(ctx context.Context) (<-chan ContainerEvent, <-cha
messages <- ContainerEvent{ messages <- ContainerEvent{
ActorID: message.Actor.ID[:12], ActorID: message.Actor.ID[:12],
Name: message.Action, Name: message.Action,
Host: d.host,
} }
} }
} }
} }
}() }()
return messages, errors return errors
} }
func (d *dockerClient) ContainerLogReader(ctx context.Context, id string) (io.ReadCloser, error) { func (d *dockerClient) ContainerLogReader(ctx context.Context, id string) (io.ReadCloser, error) {
@@ -366,6 +371,10 @@ func (d *dockerClient) Ping(ctx context.Context) (types.Ping, error) {
return d.cli.Ping(ctx) return d.cli.Ping(ctx)
} }
func (d *dockerClient) Host() string {
return d.host
}
var PARENTHESIS_RE = regexp.MustCompile(`\(([a-zA-Z]+)\)`) var PARENTHESIS_RE = regexp.MustCompile(`\(([a-zA-Z]+)\)`)
func findBetweenParentheses(s string) string { func findBetweenParentheses(s string) string {

View File

@@ -44,12 +44,7 @@ func (m *mockedProxy) ContainerLogs(ctx context.Context, id string, options type
func (m *mockedProxy) ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error) { func (m *mockedProxy) ContainerInspect(ctx context.Context, containerID string) (types.ContainerJSON, error) {
args := m.Called(ctx, containerID) args := m.Called(ctx, containerID)
json, ok := args.Get(0).(types.ContainerJSON) return args.Get(0).(types.ContainerJSON), args.Error(1)
if !ok && args.Get(0) != nil {
panic("proxies return value is not of type types.ContainerJSON")
}
return json, args.Error(1)
} }
func (m *mockedProxy) ContainerStats(ctx context.Context, containerID string, stream bool) (types.ContainerStats, error) { func (m *mockedProxy) ContainerStats(ctx context.Context, containerID string, stream bool) (types.ContainerStats, error) {
@@ -59,7 +54,7 @@ func (m *mockedProxy) ContainerStats(ctx context.Context, containerID string, st
func Test_dockerClient_ListContainers_null(t *testing.T) { func Test_dockerClient_ListContainers_null(t *testing.T) {
proxy := new(mockedProxy) proxy := new(mockedProxy)
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(nil, nil) proxy.On("ContainerList", mock.Anything, mock.Anything).Return(nil, nil)
client := &dockerClient{proxy, filters.NewArgs()} client := &dockerClient{proxy, filters.NewArgs(), "localhost"}
list, err := client.ListContainers() list, err := client.ListContainers()
assert.Empty(t, list, "list should be empty") assert.Empty(t, list, "list should be empty")
@@ -71,7 +66,7 @@ func Test_dockerClient_ListContainers_null(t *testing.T) {
func Test_dockerClient_ListContainers_error(t *testing.T) { func Test_dockerClient_ListContainers_error(t *testing.T) {
proxy := new(mockedProxy) proxy := new(mockedProxy)
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(nil, errors.New("test")) proxy.On("ContainerList", mock.Anything, mock.Anything).Return(nil, errors.New("test"))
client := &dockerClient{proxy, filters.NewArgs()} client := &dockerClient{proxy, filters.NewArgs(), "localhost"}
list, err := client.ListContainers() list, err := client.ListContainers()
assert.Nil(t, list, "list should be nil") assert.Nil(t, list, "list should be nil")
@@ -94,7 +89,7 @@ func Test_dockerClient_ListContainers_happy(t *testing.T) {
proxy := new(mockedProxy) proxy := new(mockedProxy)
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil) proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil)
client := &dockerClient{proxy, filters.NewArgs()} client := &dockerClient{proxy, filters.NewArgs(), "localhost"}
list, err := client.ListContainers() list, err := client.ListContainers()
require.NoError(t, err, "error should not return an error.") require.NoError(t, err, "error should not return an error.")
@@ -104,11 +99,13 @@ func Test_dockerClient_ListContainers_happy(t *testing.T) {
ID: "1234567890_a", ID: "1234567890_a",
Name: "a_test_container", Name: "a_test_container",
Names: []string{"/a_test_container"}, Names: []string{"/a_test_container"},
Host: "localhost",
}, },
{ {
ID: "abcdefghijkl", ID: "abcdefghijkl",
Name: "z_test_container", Name: "z_test_container",
Names: []string{"/z_test_container"}, Names: []string{"/z_test_container"},
Host: "localhost",
}, },
}) })
@@ -132,7 +129,7 @@ func Test_dockerClient_ContainerLogs_happy(t *testing.T) {
json := types.ContainerJSON{Config: &container.Config{Tty: false}} json := types.ContainerJSON{Config: &container.Config{Tty: false}}
proxy.On("ContainerInspect", mock.Anything, id).Return(json, nil) proxy.On("ContainerInspect", mock.Anything, id).Return(json, nil)
client := &dockerClient{proxy, filters.NewArgs()} client := &dockerClient{proxy, filters.NewArgs(), "localhost"}
logReader, _ := client.ContainerLogs(context.Background(), id, "since", STDALL) logReader, _ := client.ContainerLogs(context.Background(), id, "since", STDALL)
actual, _ := io.ReadAll(logReader) actual, _ := io.ReadAll(logReader)
@@ -153,7 +150,7 @@ func Test_dockerClient_ContainerLogs_happy_with_tty(t *testing.T) {
json := types.ContainerJSON{Config: &container.Config{Tty: true}} json := types.ContainerJSON{Config: &container.Config{Tty: true}}
proxy.On("ContainerInspect", mock.Anything, id).Return(json, nil) proxy.On("ContainerInspect", mock.Anything, id).Return(json, nil)
client := &dockerClient{proxy, filters.NewArgs()} client := &dockerClient{proxy, filters.NewArgs(), "localhost"}
logReader, _ := client.ContainerLogs(context.Background(), id, "", STDALL) logReader, _ := client.ContainerLogs(context.Background(), id, "", STDALL)
actual, _ := io.ReadAll(logReader) actual, _ := io.ReadAll(logReader)
@@ -168,7 +165,7 @@ func Test_dockerClient_ContainerLogs_error(t *testing.T) {
proxy.On("ContainerLogs", mock.Anything, id, mock.Anything).Return(nil, errors.New("test")) proxy.On("ContainerLogs", mock.Anything, id, mock.Anything).Return(nil, errors.New("test"))
client := &dockerClient{proxy, filters.NewArgs()} client := &dockerClient{proxy, filters.NewArgs(), "localhost"}
reader, err := client.ContainerLogs(context.Background(), id, "", STDALL) reader, err := client.ContainerLogs(context.Background(), id, "", STDALL)
@@ -191,7 +188,7 @@ func Test_dockerClient_FindContainer_happy(t *testing.T) {
proxy := new(mockedProxy) proxy := new(mockedProxy)
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil) proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil)
client := &dockerClient{proxy, filters.NewArgs()} client := &dockerClient{proxy, filters.NewArgs(), "localhost"}
container, err := client.FindContainer("abcdefghijkl") container, err := client.FindContainer("abcdefghijkl")
require.NoError(t, err, "error should not be thrown") require.NoError(t, err, "error should not be thrown")
@@ -200,6 +197,7 @@ func Test_dockerClient_FindContainer_happy(t *testing.T) {
ID: "abcdefghijkl", ID: "abcdefghijkl",
Name: "z_test_container", Name: "z_test_container",
Names: []string{"/z_test_container"}, Names: []string{"/z_test_container"},
Host: "localhost",
}) })
proxy.AssertExpectations(t) proxy.AssertExpectations(t)
@@ -218,7 +216,7 @@ func Test_dockerClient_FindContainer_error(t *testing.T) {
proxy := new(mockedProxy) proxy := new(mockedProxy)
proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil) proxy.On("ContainerList", mock.Anything, mock.Anything).Return(containers, nil)
client := &dockerClient{proxy, filters.NewArgs()} client := &dockerClient{proxy, filters.NewArgs(), "localhost"}
_, err := client.FindContainer("not_valid") _, err := client.FindContainer("not_valid")
require.Error(t, err, "error should be thrown") require.Error(t, err, "error should be thrown")

View File

@@ -16,6 +16,7 @@ type Container struct {
State string `json:"state"` State string `json:"state"`
Status string `json:"status"` Status string `json:"status"`
Health string `json:"health,omitempty"` Health string `json:"health,omitempty"`
Host string `json:"host,omitempty"`
} }
// ContainerStat represent stats instant for a container // ContainerStat represent stats instant for a container
@@ -30,6 +31,7 @@ type ContainerStat struct {
type ContainerEvent struct { type ContainerEvent struct {
ActorID string `json:"actorId"` ActorID string `json:"actorId"`
Name string `json:"name"` Name string `json:"name"`
Host string `json:"host"`
} }
type LogPosition string type LogPosition string

View File

@@ -5,5 +5,5 @@ test("authentication", async ({ page }) => {
await page.locator('input[name="username"]').fill("foo"); await page.locator('input[name="username"]').fill("foo");
await page.locator('input[name="password"]').fill("bar"); await page.locator('input[name="password"]').fill("bar");
await page.getByRole("button", { name: "Login" }).click(); await page.getByRole("button", { name: "Login" }).click();
await expect(page.locator("p.menu-label")).toHaveText("Containers"); await expect(page.locator(".menu-label [aria-current]")).toHaveText("Containers");
}); });

View File

@@ -27,6 +27,6 @@ test.describe("es locale", () => {
test.use({ locale: "es" }); test.use({ locale: "es" });
test("translated text", async ({ page }) => { test("translated text", async ({ page }) => {
await expect(page.locator("p.menu-label").getByText("Contenedores")).toBeVisible(); await expect(page.locator(".menu-label [aria-current]").getByText("Contenedores")).toBeVisible();
}); });
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

14
main.go
View File

@@ -141,14 +141,18 @@ func createClients(args args, localClientFactory func(map[string][]string) (dock
clients := make(map[string]docker.Client) clients := make(map[string]docker.Client)
if localClient := createLocalClient(args, localClientFactory); localClient != nil { if localClient := createLocalClient(args, localClientFactory); localClient != nil {
clients["localhost"] = localClient clients[localClient.Host()] = localClient
} }
for _, host := range args.RemoteHost { for _, host := range args.RemoteHost {
log.Infof("Creating client for %s", host) log.Infof("Creating client for %s", host)
client, err := remoteClientFactory(args.Filter, host) if client, err := remoteClientFactory(args.Filter, host); err == nil {
if err == nil { if _, err := client.ListContainers(); err == nil {
clients[host] = client log.Debugf("Connected to local Docker Engine")
clients[client.Host()] = client
} else {
log.Warnf("Could not connect to remote host %s: %s", host, err)
}
} else { } else {
log.Warnf("Could not create client for %s: %s", host, err) log.Warnf("Could not create client for %s: %s", host, err)
} }
@@ -184,14 +188,12 @@ func createServer(args args, clients map[string]docker.Client) *http.Server {
func createLocalClient(args args, localClientFactory func(map[string][]string) (docker.Client, error)) docker.Client { func createLocalClient(args args, localClientFactory func(map[string][]string) (docker.Client, error)) docker.Client {
for i := 1; ; i++ { for i := 1; ; i++ {
dockerClient, err := localClientFactory(args.Filter) dockerClient, err := localClientFactory(args.Filter)
if err == nil { if err == nil {
_, err := dockerClient.ListContainers() _, err := dockerClient.ListContainers()
if err == nil { if err == nil {
log.Debugf("Connected to local Docker Engine") log.Debugf("Connected to local Docker Engine")
return dockerClient return dockerClient
} }
} }
if args.WaitForDockerSeconds > 0 { if args.WaitForDockerSeconds > 0 {

View File

@@ -19,10 +19,16 @@ func (f *fakeClient) ListContainers() ([]docker.Container, error) {
return args.Get(0).([]docker.Container), args.Error(1) return args.Get(0).([]docker.Container), args.Error(1)
} }
func (f *fakeClient) Host() string {
args := f.Called()
return args.String(0)
}
func Test_valid_localhost(t *testing.T) { func Test_valid_localhost(t *testing.T) {
fakeClientFactory := func(filter map[string][]string) (docker.Client, error) { fakeClientFactory := func(filter map[string][]string) (docker.Client, error) {
client := new(fakeClient) client := new(fakeClient)
client.On("ListContainers").Return([]docker.Container{}, nil) client.On("ListContainers").Return([]docker.Container{}, nil)
client.On("Host").Return("localhost")
return client, nil return client, nil
} }
@@ -37,6 +43,7 @@ func Test_invalid_localhost(t *testing.T) {
fakeClientFactory := func(filter map[string][]string) (docker.Client, error) { fakeClientFactory := func(filter map[string][]string) (docker.Client, error) {
client := new(fakeClient) client := new(fakeClient)
client.On("ListContainers").Return([]docker.Container{}, errors.New("error")) client.On("ListContainers").Return([]docker.Container{}, errors.New("error"))
client.On("Host").Return("localhost")
return client, nil return client, nil
} }
@@ -51,22 +58,26 @@ func Test_valid_remote(t *testing.T) {
fakeLocalClientFactory := func(filter map[string][]string) (docker.Client, error) { fakeLocalClientFactory := func(filter map[string][]string) (docker.Client, error) {
client := new(fakeClient) client := new(fakeClient)
client.On("ListContainers").Return([]docker.Container{}, errors.New("error")) client.On("ListContainers").Return([]docker.Container{}, errors.New("error"))
client.On("Host").Return("localhost")
return client, nil return client, nil
} }
fakeRemoteClientFactory := func(filter map[string][]string, host string) (docker.Client, error) { fakeRemoteClientFactory := func(filter map[string][]string, host string) (docker.Client, error) {
client := new(fakeClient) client := new(fakeClient)
client.On("ListContainers").Return([]docker.Container{}, nil)
client.On("Host").Return("test")
return client, nil return client, nil
} }
args := args{ args := args{
RemoteHost: []string{"tcp://localhost:2375"}, RemoteHost: []string{"tcp://test:2375"},
} }
clients := createClients(args, fakeLocalClientFactory, fakeRemoteClientFactory) clients := createClients(args, fakeLocalClientFactory, fakeRemoteClientFactory)
assert.Equal(t, 1, len(clients)) assert.Equal(t, 1, len(clients))
assert.Contains(t, clients, "tcp://localhost:2375") assert.Contains(t, clients, "test")
assert.NotContains(t, clients, "localhost") assert.NotContains(t, clients, "localhost")
} }
@@ -74,22 +85,25 @@ func Test_valid_remote_and_local(t *testing.T) {
fakeLocalClientFactory := func(filter map[string][]string) (docker.Client, error) { fakeLocalClientFactory := func(filter map[string][]string) (docker.Client, error) {
client := new(fakeClient) client := new(fakeClient)
client.On("ListContainers").Return([]docker.Container{}, nil) client.On("ListContainers").Return([]docker.Container{}, nil)
client.On("Host").Return("localhost")
return client, nil return client, nil
} }
fakeRemoteClientFactory := func(filter map[string][]string, host string) (docker.Client, error) { fakeRemoteClientFactory := func(filter map[string][]string, host string) (docker.Client, error) {
client := new(fakeClient) client := new(fakeClient)
client.On("ListContainers").Return([]docker.Container{}, nil)
client.On("Host").Return("test")
return client, nil return client, nil
} }
args := args{ args := args{
RemoteHost: []string{"tcp://localhost:2375"}, RemoteHost: []string{"tcp://test:2375"},
} }
clients := createClients(args, fakeLocalClientFactory, fakeRemoteClientFactory) clients := createClients(args, fakeLocalClientFactory, fakeRemoteClientFactory)
assert.Equal(t, 2, len(clients)) assert.Equal(t, 2, len(clients))
assert.Contains(t, clients, "tcp://localhost:2375") assert.Contains(t, clients, "test")
assert.Contains(t, clients, "localhost") assert.Contains(t, clients, "localhost")
} }
@@ -97,11 +111,13 @@ func Test_no_clients(t *testing.T) {
fakeLocalClientFactory := func(filter map[string][]string) (docker.Client, error) { fakeLocalClientFactory := func(filter map[string][]string) (docker.Client, error) {
client := new(fakeClient) client := new(fakeClient)
client.On("ListContainers").Return([]docker.Container{}, errors.New("error")) client.On("ListContainers").Return([]docker.Container{}, errors.New("error"))
client.On("Host").Return("localhost")
return client, nil return client, nil
} }
fakeRemoteClientFactory := func(filter map[string][]string, host string) (docker.Client, error) { fakeRemoteClientFactory := func(filter map[string][]string, host string) (docker.Client, error) {
client := new(fakeClient) client := new(fakeClient)
client.On("Host").Return("test")
return client, nil return client, nil
} }

View File

@@ -74,6 +74,7 @@ Content-Type: text/html
/* snapshot: Test_handler_between_dates */ /* snapshot: Test_handler_between_dates */
HTTP/1.1 200 OK HTTP/1.1 200 OK
Connection: close Connection: close
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: application/ld+json; charset=UTF-8 Content-Type: application/ld+json; charset=UTF-8
{"m":"INFO Testing logs...","ts":1589396137772,"id":1122614848,"l":"info","s":"stdout"} {"m":"INFO Testing logs...","ts":1589396137772,"id":1122614848,"l":"info","s":"stdout"}
@@ -97,6 +98,7 @@ Connection: close
Cache-Control: no-transform Cache-Control: no-transform
Cache-Control: no-cache Cache-Control: no-cache
Connection: keep-alive Connection: keep-alive
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: text/event-stream Content-Type: text/event-stream
X-Accel-Buffering: no X-Accel-Buffering: no
@@ -109,6 +111,7 @@ Connection: close
Cache-Control: no-transform Cache-Control: no-transform
Cache-Control: no-cache Cache-Control: no-cache
Connection: keep-alive Connection: keep-alive
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: text/event-stream Content-Type: text/event-stream
X-Accel-Buffering: no X-Accel-Buffering: no
@@ -121,11 +124,12 @@ data: []
event: container-start event: container-start
data: {"actorId":"1234","name":"start"} data: {"actorId":"1234","name":"start","host":"localhost"}
/* snapshot: Test_handler_streamLogs_error_finding_container */ /* snapshot: Test_handler_streamLogs_error_finding_container */
HTTP/1.1 404 Not Found HTTP/1.1 404 Not Found
Connection: close Connection: close
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: text/plain; charset=utf-8 Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff X-Content-Type-Options: nosniff
@@ -137,6 +141,7 @@ Connection: close
Cache-Control: no-transform Cache-Control: no-transform
Cache-Control: no-cache Cache-Control: no-cache
Connection: keep-alive Connection: keep-alive
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: text/plain; charset=utf-8 Content-Type: text/plain; charset=utf-8
X-Accel-Buffering: no X-Accel-Buffering: no
X-Content-Type-Options: nosniff X-Content-Type-Options: nosniff
@@ -146,6 +151,7 @@ test error
/* snapshot: Test_handler_streamLogs_error_std */ /* snapshot: Test_handler_streamLogs_error_std */
HTTP/1.1 400 Bad Request HTTP/1.1 400 Bad Request
Connection: close Connection: close
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: text/plain; charset=utf-8 Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff X-Content-Type-Options: nosniff
@@ -157,6 +163,7 @@ Connection: close
Cache-Control: no-transform Cache-Control: no-transform
Cache-Control: no-cache Cache-Control: no-cache
Connection: keep-alive Connection: keep-alive
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: text/event-stream Content-Type: text/event-stream
X-Accel-Buffering: no X-Accel-Buffering: no
@@ -171,6 +178,7 @@ Connection: close
Cache-Control: no-transform Cache-Control: no-transform
Cache-Control: no-cache Cache-Control: no-cache
Connection: keep-alive Connection: keep-alive
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: text/event-stream Content-Type: text/event-stream
X-Accel-Buffering: no X-Accel-Buffering: no
@@ -183,6 +191,7 @@ Connection: close
Cache-Control: no-transform Cache-Control: no-transform
Cache-Control: no-cache Cache-Control: no-cache
Connection: keep-alive Connection: keep-alive
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; manifest-src 'self'; connect-src 'self' api.github.com;
Content-Type: text/event-stream Content-Type: text/event-stream
X-Accel-Buffering: no X-Accel-Buffering: no

View File

@@ -27,17 +27,16 @@ func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
client := h.clientFromRequest(r) events := make(chan docker.ContainerEvent)
events, err := client.Events(ctx)
stats := make(chan docker.ContainerStat) stats := make(chan docker.ContainerStat)
for _, client := range h.clients {
client.Events(ctx, events)
if err := sendContainersJSON(client, w); err != nil { if err := sendContainersJSON(client, w); err != nil {
log.Errorf("error while encoding containers to stream: %v", err) log.Errorf("error while encoding containers to stream: %v", err)
} }
f.Flush()
if containers, err := client.ListContainers(); err == nil { if containers, err := client.ListContainers(); err == nil {
go func() { go func(client docker.Client) {
for _, c := range containers { for _, c := range containers {
if c.State == "running" { if c.State == "running" {
if err := client.ContainerStats(ctx, c.ID, stats); err != nil && !errors.Is(err, context.Canceled) { if err := client.ContainerStats(ctx, c.ID, stats); err != nil && !errors.Is(err, context.Canceled) {
@@ -45,8 +44,11 @@ func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) {
} }
} }
} }
}() }(client)
} }
}
f.Flush()
for { for {
select { select {
@@ -66,10 +68,11 @@ 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 := client.ContainerStats(ctx, event.ActorID, stats); err != nil && !errors.Is(err, context.Canceled) {
if err := h.clients[event.Host].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(client, w); err != nil { if err := sendContainersJSON(h.clients[event.Host], w); err != nil {
log.Errorf("error encoding containers to stream: %v", err) log.Errorf("error encoding containers to stream: %v", err)
return return
} }
@@ -105,8 +108,6 @@ func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) {
} }
case <-ctx.Done(): case <-ctx.Done():
return return
case <-err:
return
} }
} }
} }

View File

@@ -15,6 +15,7 @@ import (
"github.com/amir20/dozzle/docker" "github.com/amir20/dozzle/docker"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/go-chi/chi/v5"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -50,7 +51,7 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
from, _ := time.Parse(time.RFC3339, r.URL.Query().Get("from")) from, _ := time.Parse(time.RFC3339, r.URL.Query().Get("from"))
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 := chi.URLParam(r, "id")
var stdTypes docker.StdType var stdTypes docker.StdType
if r.URL.Query().Has("stdout") { if r.URL.Query().Has("stdout") {
@@ -89,11 +90,7 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
} }
func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) { func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id") id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "id is required", http.StatusBadRequest)
return
}
var stdTypes docker.StdType var stdTypes docker.StdType
if r.URL.Query().Has("stdout") { if r.URL.Query().Has("stdout") {

View File

@@ -58,10 +58,10 @@ func createRouter(h *handler) *chi.Mux {
r.Route(base, func(r chi.Router) { r.Route(base, func(r chi.Router) {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(authorizationRequired) r.Use(authorizationRequired)
r.Get("/api/logs/stream", h.streamLogs) r.Get("/api/logs/stream/{host}/{id}", h.streamLogs)
r.Get("/api/logs/download/{host}/{id}", h.downloadLogs) //TODO
r.Get("/api/logs/{host}/{id}", h.fetchLogsBetweenDates)
r.Get("/api/events/stream", h.streamEvents) r.Get("/api/events/stream", h.streamEvents)
r.Get("/api/logs/download", h.downloadLogs)
r.Get("/api/logs", h.fetchLogsBetweenDates)
r.Get("/logout", h.clearSession) r.Get("/logout", h.clearSession)
r.Get("/version", h.version) r.Get("/version", h.version)
}) })
@@ -195,11 +195,12 @@ func (h *handler) healthcheck(w http.ResponseWriter, r *http.Request) {
} }
func (h *handler) clientFromRequest(r *http.Request) docker.Client { func (h *handler) clientFromRequest(r *http.Request) docker.Client {
if !r.URL.Query().Has("host") { host := chi.URLParam(r, "host")
log.Fatalf("No host parameter found in request %v", r.URL)
if host == "" {
log.Fatalf("No host found for url %v", r.URL)
} }
host := r.URL.Query().Get("host")
if client, ok := h.clients[host]; ok { if client, ok := h.clients[host]; ok {
return client return client
} }

View File

@@ -111,7 +111,7 @@ func Test_createRoutes_username_password(t *testing.T) {
func Test_createRoutes_username_password_invalid(t *testing.T) { func Test_createRoutes_username_password_invalid(t *testing.T) {
handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password"}) handler := createHandler(nil, nil, Config{Base: "/", Username: "amir", Password: "password"})
req, err := http.NewRequest("GET", "/api/logs/stream?id=123&stdout=1&stderr=1", nil) req, err := http.NewRequest("GET", "/api/logs/stream/localhost/123?stdout=1&stderr=1", nil)
require.NoError(t, err, "NewRequest should not return an error.") require.NoError(t, err, "NewRequest should not return an error.")
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
@@ -182,7 +182,7 @@ func Test_createRoutes_username_password_valid_session(t *testing.T) {
handler := createHandler(mockedClient, nil, Config{Base: "/", Username: "amir", Password: "password"}) handler := createHandler(mockedClient, nil, Config{Base: "/", Username: "amir", Password: "password"})
// Get cookie first // Get cookie first
req, err := http.NewRequest("GET", "/api/logs/stream?id=123&stdout=1&stderr=1&host=localhost", nil) req, err := http.NewRequest("GET", "/api/logs/stream/localhost/123?stdout=1&stderr=1", nil)
require.NoError(t, err, "NewRequest should not return an error.") require.NoError(t, err, "NewRequest should not return an error.")
session, _ := store.Get(req, sessionName) session, _ := store.Get(req, sessionName)
session.Values[authorityKey] = time.Now().Unix() session.Values[authorityKey] = time.Now().Unix()
@@ -191,7 +191,7 @@ func Test_createRoutes_username_password_valid_session(t *testing.T) {
cookies := recorder.Result().Cookies() cookies := recorder.Result().Cookies()
// Test with cookie // Test with cookie
req, err = http.NewRequest("GET", "/api/logs/stream?id=123&stdout=1&stderr=1&host=localhost", nil) req, err = http.NewRequest("GET", "/api/logs/stream/localhost/123?stdout=1&stderr=1", nil)
require.NoError(t, err, "NewRequest should not return an error.") require.NoError(t, err, "NewRequest should not return an error.")
req.AddCookie(cookies[0]) req.AddCookie(cookies[0])
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
@@ -204,7 +204,7 @@ func Test_createRoutes_username_password_invalid_session(t *testing.T) {
mockedClient.On("FindContainer", "123").Return(docker.Container{ID: "123"}, nil) mockedClient.On("FindContainer", "123").Return(docker.Container{ID: "123"}, nil)
mockedClient.On("ContainerLogs", mock.Anything, "since", docker.STDALL).Return(io.NopCloser(strings.NewReader("test data")), io.EOF) mockedClient.On("ContainerLogs", mock.Anything, "since", docker.STDALL).Return(io.NopCloser(strings.NewReader("test data")), io.EOF)
handler := createHandler(mockedClient, nil, Config{Base: "/", Username: "amir", Password: "password"}) handler := createHandler(mockedClient, nil, Config{Base: "/", Username: "amir", Password: "password"})
req, err := http.NewRequest("GET", "/api/logs/stream?id=123&stdout=1&stderr=1&host=localhost", nil) req, err := http.NewRequest("GET", "/api/logs/stream/localhost/123?stdout=1&stderr=1", nil)
require.NoError(t, err, "NewRequest should not return an error.") require.NoError(t, err, "NewRequest should not return an error.")
req.AddCookie(&http.Cookie{Name: "session", Value: "baddata"}) req.AddCookie(&http.Cookie{Name: "session", Value: "baddata"})
rr := httptest.NewRecorder() rr := httptest.NewRecorder()

71
web/routes_events_test.go Normal file
View File

@@ -0,0 +1,71 @@
package web
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/amir20/dozzle/docker"
"github.com/beme/abide"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func Test_handler_streamEvents_happy(t *testing.T) {
context, cancel := context.WithCancel(context.Background())
req, err := http.NewRequestWithContext(context, "GET", "/api/events/stream", nil)
require.NoError(t, err, "NewRequest should not return an error.")
mockedClient := new(MockedClient)
errChannel := make(chan error)
mockedClient.On("ListContainers").Return([]docker.Container{}, nil)
mockedClient.On("Events", mock.Anything, mock.AnythingOfType("chan<- docker.ContainerEvent")).Return(errChannel).Run(func(args mock.Arguments) {
messages := args.Get(1).(chan<- docker.ContainerEvent)
go func() {
messages <- docker.ContainerEvent{
Name: "start",
ActorID: "1234",
Host: "localhost",
}
messages <- docker.ContainerEvent{
Name: "something-random",
ActorID: "1234",
Host: "localhost",
}
cancel()
}()
})
handler := createDefaultHandler(mockedClient)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
abide.AssertHTTPResponse(t, t.Name(), rr.Result())
mockedClient.AssertExpectations(t)
}
func Test_handler_streamEvents_error_request(t *testing.T) {
req, err := http.NewRequest("GET", "/api/events/stream", nil)
require.NoError(t, err, "NewRequest should not return an error.")
mockedClient := new(MockedClient)
errChannel := make(chan error)
mockedClient.On("Events", mock.Anything, mock.Anything).Return(errChannel)
mockedClient.On("ListContainers").Return([]docker.Container{}, nil)
ctx, cancel := context.WithCancel(context.Background())
req = req.WithContext(ctx)
go func() {
cancel()
}()
handler := createDefaultHandler(mockedClient)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
abide.AssertHTTPResponse(t, t.Name(), rr.Result())
mockedClient.AssertExpectations(t)
}

View File

@@ -1,7 +1,6 @@
package web package web
import ( import (
"context"
"errors" "errors"
"io" "io"
"time" "time"
@@ -19,12 +18,11 @@ import (
func Test_handler_streamLogs_happy(t *testing.T) { func Test_handler_streamLogs_happy(t *testing.T) {
id := "123456" id := "123456"
req, err := http.NewRequest("GET", "/api/logs/stream", nil) req, err := http.NewRequest("GET", "/api/logs/stream/localhost/"+id, nil)
q := req.URL.Query() q := req.URL.Query()
q.Add("id", id)
q.Add("stdout", "true") q.Add("stdout", "true")
q.Add("stderr", "true") q.Add("stderr", "true")
q.Add("host", "localhost")
req.URL.RawQuery = q.Encode() req.URL.RawQuery = q.Encode()
require.NoError(t, err, "NewRequest should not return an error.") require.NoError(t, err, "NewRequest should not return an error.")
@@ -33,11 +31,7 @@ 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, "", docker.STDALL).Return(reader, nil) mockedClient.On("ContainerLogs", mock.Anything, mock.Anything, "", docker.STDALL).Return(reader, nil)
clients := map[string]docker.Client{ handler := createDefaultHandler(mockedClient)
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.streamLogs)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
abide.AssertHTTPResponse(t, t.Name(), rr.Result()) abide.AssertHTTPResponse(t, t.Name(), rr.Result())
@@ -46,12 +40,11 @@ func Test_handler_streamLogs_happy(t *testing.T) {
func Test_handler_streamLogs_happy_with_id(t *testing.T) { func Test_handler_streamLogs_happy_with_id(t *testing.T) {
id := "123456" id := "123456"
req, err := http.NewRequest("GET", "/api/logs/stream", nil) req, err := http.NewRequest("GET", "/api/logs/stream/localhost/"+id, nil)
q := req.URL.Query() q := req.URL.Query()
q.Add("id", id)
q.Add("stdout", "true") q.Add("stdout", "true")
q.Add("stderr", "true") q.Add("stderr", "true")
q.Add("host", "localhost")
req.URL.RawQuery = q.Encode() req.URL.RawQuery = q.Encode()
require.NoError(t, err, "NewRequest should not return an error.") require.NoError(t, err, "NewRequest should not return an error.")
@@ -60,11 +53,7 @@ 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, "", docker.STDALL).Return(reader, nil) mockedClient.On("ContainerLogs", mock.Anything, mock.Anything, "", docker.STDALL).Return(reader, nil)
clients := map[string]docker.Client{ handler := createDefaultHandler(mockedClient)
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.streamLogs)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
abide.AssertHTTPResponse(t, t.Name(), rr.Result()) abide.AssertHTTPResponse(t, t.Name(), rr.Result())
@@ -73,12 +62,11 @@ func Test_handler_streamLogs_happy_with_id(t *testing.T) {
func Test_handler_streamLogs_happy_container_stopped(t *testing.T) { func Test_handler_streamLogs_happy_container_stopped(t *testing.T) {
id := "123456" id := "123456"
req, err := http.NewRequest("GET", "/api/logs/stream", nil) req, err := http.NewRequest("GET", "/api/logs/stream/localhost/"+id, nil)
q := req.URL.Query() q := req.URL.Query()
q.Add("id", id)
q.Add("stdout", "true") q.Add("stdout", "true")
q.Add("stderr", "true") q.Add("stderr", "true")
q.Add("host", "localhost")
req.URL.RawQuery = q.Encode() req.URL.RawQuery = q.Encode()
require.NoError(t, err, "NewRequest should not return an error.") require.NoError(t, err, "NewRequest should not return an error.")
@@ -86,11 +74,7 @@ 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, "", docker.STDALL).Return(io.NopCloser(strings.NewReader("")), io.EOF) mockedClient.On("ContainerLogs", mock.Anything, id, "", docker.STDALL).Return(io.NopCloser(strings.NewReader("")), io.EOF)
clients := map[string]docker.Client{ handler := createDefaultHandler(mockedClient)
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.streamLogs)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
abide.AssertHTTPResponse(t, t.Name(), rr.Result()) abide.AssertHTTPResponse(t, t.Name(), rr.Result())
@@ -99,23 +83,18 @@ func Test_handler_streamLogs_happy_container_stopped(t *testing.T) {
func Test_handler_streamLogs_error_finding_container(t *testing.T) { func Test_handler_streamLogs_error_finding_container(t *testing.T) {
id := "123456" id := "123456"
req, err := http.NewRequest("GET", "/api/logs/stream", nil) req, err := http.NewRequest("GET", "/api/logs/stream/localhost/"+id, nil)
q := req.URL.Query() q := req.URL.Query()
q.Add("id", id)
q.Add("stdout", "true") q.Add("stdout", "true")
q.Add("stderr", "true") q.Add("stderr", "true")
q.Add("host", "localhost")
req.URL.RawQuery = q.Encode() req.URL.RawQuery = q.Encode()
require.NoError(t, err, "NewRequest should not return an error.") require.NoError(t, err, "NewRequest should not return an error.")
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"))
clients := map[string]docker.Client{ handler := createDefaultHandler(mockedClient)
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.streamLogs)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
abide.AssertHTTPResponse(t, t.Name(), rr.Result()) abide.AssertHTTPResponse(t, t.Name(), rr.Result())
@@ -124,12 +103,11 @@ func Test_handler_streamLogs_error_finding_container(t *testing.T) {
func Test_handler_streamLogs_error_reading(t *testing.T) { func Test_handler_streamLogs_error_reading(t *testing.T) {
id := "123456" id := "123456"
req, err := http.NewRequest("GET", "/api/logs/stream", nil) req, err := http.NewRequest("GET", "/api/logs/stream/localhost/"+id, nil)
q := req.URL.Query() q := req.URL.Query()
q.Add("id", id)
q.Add("stdout", "true") q.Add("stdout", "true")
q.Add("stderr", "true") q.Add("stderr", "true")
q.Add("host", "localhost")
req.URL.RawQuery = q.Encode() req.URL.RawQuery = q.Encode()
require.NoError(t, err, "NewRequest should not return an error.") require.NoError(t, err, "NewRequest should not return an error.")
@@ -137,11 +115,7 @@ 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, "", docker.STDALL).Return(io.NopCloser(strings.NewReader("")), errors.New("test error")) mockedClient.On("ContainerLogs", mock.Anything, id, "", docker.STDALL).Return(io.NopCloser(strings.NewReader("")), errors.New("test error"))
clients := map[string]docker.Client{ handler := createDefaultHandler(mockedClient)
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.streamLogs)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
abide.AssertHTTPResponse(t, t.Name(), rr.Result()) abide.AssertHTTPResponse(t, t.Name(), rr.Result())
@@ -150,115 +124,13 @@ func Test_handler_streamLogs_error_reading(t *testing.T) {
func Test_handler_streamLogs_error_std(t *testing.T) { func Test_handler_streamLogs_error_std(t *testing.T) {
id := "123456" id := "123456"
req, err := http.NewRequest("GET", "/api/logs/stream", nil) req, err := http.NewRequest("GET", "/api/logs/stream/localhost/"+id, nil)
q := req.URL.Query()
q.Add("id", id)
q.Add("host", "localhost")
req.URL.RawQuery = q.Encode()
require.NoError(t, err, "NewRequest should not return an error.") require.NoError(t, err, "NewRequest should not return an error.")
mockedClient := new(MockedClient) mockedClient := new(MockedClient)
clients := map[string]docker.Client{ handler := createDefaultHandler(mockedClient)
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.streamLogs)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
abide.AssertHTTPResponse(t, t.Name(), rr.Result())
mockedClient.AssertExpectations(t)
}
func Test_handler_streamEvents_happy(t *testing.T) {
req, err := http.NewRequest("GET", "/api/events/stream", nil)
require.NoError(t, err, "NewRequest should not return an error.")
q := req.URL.Query()
q.Add("host", "localhost")
req.URL.RawQuery = q.Encode()
mockedClient := new(MockedClient)
messages := make(chan docker.ContainerEvent)
errChannel := make(chan error)
mockedClient.On("Events", mock.Anything).Return(messages, errChannel)
mockedClient.On("ListContainers").Return([]docker.Container{}, nil)
go func() {
messages <- docker.ContainerEvent{
Name: "start",
ActorID: "1234",
}
messages <- docker.ContainerEvent{
Name: "something-random",
ActorID: "1234",
}
close(messages)
}()
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)
abide.AssertHTTPResponse(t, t.Name(), rr.Result())
mockedClient.AssertExpectations(t)
}
func Test_handler_streamEvents_error(t *testing.T) {
req, err := http.NewRequest("GET", "/api/events/stream", nil)
require.NoError(t, err, "NewRequest should not return an error.")
q := req.URL.Query()
q.Add("host", "localhost")
req.URL.RawQuery = q.Encode()
mockedClient := new(MockedClient)
messages := make(chan docker.ContainerEvent)
errChannel := make(chan error)
mockedClient.On("Events", mock.Anything).Return(messages, errChannel)
mockedClient.On("ListContainers").Return([]docker.Container{}, nil)
go func() {
errChannel <- errors.New("fake error")
close(messages)
}()
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)
abide.AssertHTTPResponse(t, t.Name(), rr.Result())
mockedClient.AssertExpectations(t)
}
func Test_handler_streamEvents_error_request(t *testing.T) {
req, err := http.NewRequest("GET", "/api/events/stream", nil)
require.NoError(t, err, "NewRequest should not return an error.")
q := req.URL.Query()
q.Add("host", "localhost")
req.URL.RawQuery = q.Encode()
mockedClient := new(MockedClient)
messages := make(chan docker.ContainerEvent)
errChannel := make(chan error)
mockedClient.On("Events", mock.Anything).Return(messages, errChannel)
mockedClient.On("ListContainers").Return([]docker.Container{}, nil)
ctx, cancel := context.WithCancel(context.Background())
req = req.WithContext(ctx)
go func() {
cancel()
}()
clients := map[string]docker.Client{
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.streamEvents)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
abide.AssertHTTPResponse(t, t.Name(), rr.Result()) abide.AssertHTTPResponse(t, t.Name(), rr.Result())
@@ -267,7 +139,7 @@ func Test_handler_streamEvents_error_request(t *testing.T) {
// for /api/logs // for /api/logs
func Test_handler_between_dates(t *testing.T) { func Test_handler_between_dates(t *testing.T) {
req, err := http.NewRequest("GET", "/api/logs", nil) req, err := http.NewRequest("GET", "/api/logs/localhost/123456", nil)
require.NoError(t, err, "NewRequest should not return an error.") require.NoError(t, err, "NewRequest should not return an error.")
from, _ := time.Parse(time.RFC3339, "2018-01-01T00:00:00Z") from, _ := time.Parse(time.RFC3339, "2018-01-01T00:00:00Z")
@@ -276,21 +148,16 @@ func Test_handler_between_dates(t *testing.T) {
q := req.URL.Query() q := req.URL.Query()
q.Add("from", from.Format(time.RFC3339)) q.Add("from", from.Format(time.RFC3339))
q.Add("to", to.Format(time.RFC3339)) q.Add("to", to.Format(time.RFC3339))
q.Add("id", "123456")
q.Add("stdout", "true") q.Add("stdout", "true")
q.Add("stderr", "true") q.Add("stderr", "true")
q.Add("host", "localhost")
req.URL.RawQuery = q.Encode() req.URL.RawQuery = q.Encode()
mockedClient := new(MockedClient) mockedClient := new(MockedClient)
reader := io.NopCloser(strings.NewReader("OUT2020-05-13T18:55:37.772853839Z INFO Testing logs...\nERR2020-05-13T18:55:37.772853839Z INFO Testing logs...\n")) reader := io.NopCloser(strings.NewReader("OUT2020-05-13T18:55:37.772853839Z INFO Testing logs...\nERR2020-05-13T18:55:37.772853839Z INFO Testing logs...\n"))
mockedClient.On("ContainerLogsBetweenDates", mock.Anything, "123456", from, to, docker.STDALL).Return(reader, nil) mockedClient.On("ContainerLogsBetweenDates", mock.Anything, "123456", from, to, docker.STDALL).Return(reader, nil)
clients := map[string]docker.Client{ handler := createDefaultHandler(mockedClient)
"localhost": mockedClient,
}
h := handler{clients: clients, config: &Config{}}
handler := http.HandlerFunc(h.fetchLogsBetweenDates)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req) handler.ServeHTTP(rr, req)
abide.AssertHTTPResponse(t, t.Name(), rr.Result()) abide.AssertHTTPResponse(t, t.Name(), rr.Result())

View File

@@ -35,18 +35,9 @@ func (m *MockedClient) ContainerLogs(ctx context.Context, id string, since strin
return args.Get(0).(io.ReadCloser), args.Error(1) return args.Get(0).(io.ReadCloser), args.Error(1)
} }
func (m *MockedClient) Events(ctx context.Context) (<-chan docker.ContainerEvent, <-chan error) { func (m *MockedClient) Events(ctx context.Context, events chan<- docker.ContainerEvent) <-chan error {
args := m.Called(ctx) args := m.Called(ctx, events)
channel, ok := args.Get(0).(chan docker.ContainerEvent) return args.Get(0).(chan error)
if !ok {
panic("channel is not of type chan events.Message")
}
err, ok := args.Get(1).(chan error)
if !ok {
panic("error is not of type chan error")
}
return channel, err
} }
func (m *MockedClient) ContainerStats(context.Context, string, chan<- docker.ContainerStat) error { func (m *MockedClient) ContainerStats(context.Context, string, chan<- docker.ContainerStat) error {
@@ -58,6 +49,11 @@ func (m *MockedClient) ContainerLogsBetweenDates(ctx context.Context, id string,
return args.Get(0).(io.ReadCloser), args.Error(1) return args.Get(0).(io.ReadCloser), args.Error(1)
} }
func (m *MockedClient) Host() string {
args := m.Called()
return args.String(0)
}
func createHandler(client docker.Client, content fs.FS, config Config) *chi.Mux { func createHandler(client docker.Client, content fs.FS, config Config) *chi.Mux {
if client == nil { if client == nil {
client = new(MockedClient) client = new(MockedClient)
@@ -79,3 +75,7 @@ func createHandler(client docker.Client, content fs.FS, config Config) *chi.Mux
config: &config, config: &config,
}) })
} }
func createDefaultHandler(client docker.Client) *chi.Mux {
return createHandler(client, nil, Config{Base: "/"})
}