feat: enable merging logs for all containers in a host (#3492)
2
assets/auto-imports.d.ts
vendored
@@ -234,6 +234,7 @@ declare global {
|
||||
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
|
||||
const useGroupedStream: typeof import('./composable/eventStreams')['useGroupedStream']
|
||||
const useHead: typeof import('@vueuse/head')['useHead']
|
||||
const useHostStream: typeof import('./composable/eventStreams')['useHostStream']
|
||||
const useHosts: typeof import('./stores/hosts')['useHosts']
|
||||
const useI18n: typeof import('vue-i18n')['useI18n']
|
||||
const useId: typeof import('vue')['useId']
|
||||
@@ -621,6 +622,7 @@ declare module 'vue' {
|
||||
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
|
||||
readonly useGroupedStream: UnwrapRef<typeof import('./composable/eventStreams')['useGroupedStream']>
|
||||
readonly useHead: UnwrapRef<typeof import('@vueuse/head')['useHead']>
|
||||
readonly useHostStream: UnwrapRef<typeof import('./composable/eventStreams')['useHostStream']>
|
||||
readonly useHosts: UnwrapRef<typeof import('./stores/hosts')['useHosts']>
|
||||
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
|
||||
readonly useId: UnwrapRef<typeof import('vue')['useId']>
|
||||
|
||||
2
assets/components.d.ts
vendored
@@ -39,6 +39,7 @@ declare module 'vue' {
|
||||
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default']
|
||||
HostIcon: typeof import('./components/common/HostIcon.vue')['default']
|
||||
HostList: typeof import('./components/HostList.vue')['default']
|
||||
HostLog: typeof import('./components/HostViewer/HostLog.vue')['default']
|
||||
HostMenu: typeof import('./components/HostMenu.vue')['default']
|
||||
'Ic:sharpKeyboardReturn': typeof import('~icons/ic/sharp-keyboard-return')['default']
|
||||
IndeterminateBar: typeof import('./components/common/IndeterminateBar.vue')['default']
|
||||
@@ -91,6 +92,7 @@ declare module 'vue' {
|
||||
'Ph:dotsThreeVerticalBold': typeof import('~icons/ph/dots-three-vertical-bold')['default']
|
||||
'Ph:fileSql': typeof import('~icons/ph/file-sql')['default']
|
||||
'Ph:globeSimple': typeof import('~icons/ph/globe-simple')['default']
|
||||
'Ph:listBulletsFill': typeof import('~icons/ph/list-bullets-fill')['default']
|
||||
'Ph:memory': typeof import('~icons/ph/memory')['default']
|
||||
'Ph:stack': typeof import('~icons/ph/stack')['default']
|
||||
'Ph:stackSimple': typeof import('~icons/ph/stack-simple')['default']
|
||||
|
||||
@@ -6,7 +6,18 @@
|
||||
<a @click.prevent="setHost(null)" class="link-primary">{{ $t("label.hosts") }}</a>
|
||||
</li>
|
||||
<li v-if="sessionHost && hosts[sessionHost]" class="cursor-default">
|
||||
{{ hosts[sessionHost].name }}
|
||||
<router-link
|
||||
:to="{
|
||||
name: '/host/[id]',
|
||||
params: { id: hosts[sessionHost].id },
|
||||
}"
|
||||
class="btn btn-outline btn-primary btn-xs"
|
||||
active-class="btn-active"
|
||||
:title="$t('tooltip.merge-hosts')"
|
||||
>
|
||||
<ph:arrows-merge />
|
||||
{{ hosts[sessionHost].name }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
43
assets/components/HostViewer/HostLog.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<ScrollableView :scrollable="scrollable" v-if="host">
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="flex flex-1 gap-1.5 truncate md:gap-2">
|
||||
<ph:computer-tower />
|
||||
<div class="inline-flex font-mono text-sm">
|
||||
<div class="font-semibold">{{ host.name }}</div>
|
||||
</div>
|
||||
<Tag class="mobile-hidden font-mono" size="small">
|
||||
{{ $t("label.container", containers.length) }}
|
||||
</Tag>
|
||||
</div>
|
||||
<MultiContainerStat class="ml-auto" :containers="containers" />
|
||||
<MultiContainerActionToolbar class="mobile-hidden" @clear="viewer?.clear()" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<ViewerWithSource
|
||||
ref="viewer"
|
||||
:stream-source="useHostStream"
|
||||
:entity="host"
|
||||
:visible-keys="new Map<string[], boolean>()"
|
||||
/>
|
||||
</template>
|
||||
</ScrollableView>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
|
||||
import { ComponentExposed } from "vue-component-type-helpers";
|
||||
const { id, scrollable = false } = defineProps<{
|
||||
id: string;
|
||||
scrollable?: boolean;
|
||||
}>();
|
||||
const store = useContainerStore();
|
||||
const { containersByHost } = storeToRefs(store);
|
||||
const { hosts } = useHosts();
|
||||
const host = computed(() => hosts.value[id]);
|
||||
const containers = computed(() => containersByHost.value?.[id].filter((c) => c.state === "running") ?? []);
|
||||
const viewer = useTemplateRef<ComponentExposed<typeof ViewerWithSource>>("viewer");
|
||||
provideLoggingContext(containers, { showContainerName: true, showHostname: false });
|
||||
</script>
|
||||
@@ -3,6 +3,7 @@
|
||||
<template #header>
|
||||
<div class="mx-2 flex items-center gap-2 md:ml-4">
|
||||
<div class="flex flex-1 gap-1.5 truncate @container md:gap-2">
|
||||
<ph:stack-simple />
|
||||
<div class="inline-flex font-mono text-sm">
|
||||
<div class="font-semibold">{{ service.name }}</div>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeRouteLeave } from "vue-router";
|
||||
const containerStore = useContainerStore();
|
||||
const { ready } = storeToRefs(containerStore);
|
||||
const route = useRoute();
|
||||
@@ -29,18 +28,16 @@ const { services, customGroups } = storeToRefs(swarmStore);
|
||||
|
||||
const showSwarm = useSessionStorage<boolean>("DOZZLE_SWARM_MODE", false);
|
||||
|
||||
if (route.meta.swarmMode) {
|
||||
showSwarm.value = true;
|
||||
} else if (route.meta.containerMode) {
|
||||
showSwarm.value = false;
|
||||
}
|
||||
|
||||
onBeforeRouteLeave((to) => {
|
||||
if (to.meta.swarmMode) {
|
||||
showSwarm.value = true;
|
||||
} else if (to.meta.containerMode) {
|
||||
showSwarm.value = false;
|
||||
}
|
||||
});
|
||||
watch(
|
||||
route,
|
||||
() => {
|
||||
if (route.meta.swarmMode) {
|
||||
showSwarm.value = true;
|
||||
} else if (route.meta.containerMode) {
|
||||
showSwarm.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
<style scoped lang="postcss"></style>
|
||||
|
||||
@@ -26,6 +26,10 @@ export function useContainerStream(container: Ref<Container>): LogStreamSource {
|
||||
return useLogStream(url, loadMoreUrl);
|
||||
}
|
||||
|
||||
export function useHostStream(host: Ref<Host>): LogStreamSource {
|
||||
return useLogStream(computed(() => `/api/hosts/${host.value.id}/logs/stream`));
|
||||
}
|
||||
|
||||
export function useStackStream(stack: Ref<Stack>): LogStreamSource {
|
||||
return useLogStream(computed(() => `/api/stacks/${stack.value.name}/logs/stream`));
|
||||
}
|
||||
|
||||
30
assets/pages/host/[id].vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<Search />
|
||||
<HostLog :id="route.params.id" :scrollable="pinnedLogs.length > 0" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
const route = useRoute("/host/[id]");
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { ready } = storeToRefs(containerStore);
|
||||
|
||||
const pinnedLogsStore = usePinnedLogsStore();
|
||||
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
|
||||
const { hosts } = useHosts();
|
||||
const host = computed(() => hosts.value[route.params.id]);
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
if (host.value?.name) {
|
||||
setTitle(host.value.name);
|
||||
} else {
|
||||
setTitle("Not Found");
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<route lang="yaml">
|
||||
meta:
|
||||
containerMode: true
|
||||
</route>
|
||||
@@ -161,9 +161,23 @@ export const useContainerStore = defineStore("container", () => {
|
||||
|
||||
const findContainerById = (id: string) => allContainersById.value[id];
|
||||
|
||||
const containersByHost = computed(() =>
|
||||
containers.value.reduce(
|
||||
(acc, container) => {
|
||||
if (!acc[container.host]) {
|
||||
acc[container.host] = [];
|
||||
}
|
||||
acc[container.host].push(container);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Container[]>,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
containers,
|
||||
allContainersById,
|
||||
containersByHost,
|
||||
visibleContainers,
|
||||
currentContainer,
|
||||
findContainerById,
|
||||
|
||||
1
assets/typed-router.d.ts
vendored
@@ -22,6 +22,7 @@ declare module 'vue-router/auto-routes' {
|
||||
'/[...all]': RouteRecordInfo<'/[...all]', '/:all(.*)', { all: ParamValue<true> }, { all: ParamValue<false> }>,
|
||||
'/container/[id]': RouteRecordInfo<'/container/[id]', '/container/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||
'/group/[name]': RouteRecordInfo<'/group/[name]', '/group/:name', { name: ParamValue<true> }, { name: ParamValue<false> }>,
|
||||
'/host/[id]': RouteRecordInfo<'/host/[id]', '/host/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
|
||||
'/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
|
||||
'/merged/[ids]': RouteRecordInfo<'/merged/[ids]', '/merged/:ids', { ids: ParamValue<true> }, { ids: ParamValue<false> }>,
|
||||
'/service/[name]': RouteRecordInfo<'/service/[name]', '/service/:name', { name: ParamValue<true> }, { name: ParamValue<false> }>,
|
||||
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -220,6 +220,13 @@ func (h *handler) streamStackLogs(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) streamHostLogs(w http.ResponseWriter, r *http.Request) {
|
||||
host := hostKey(r)
|
||||
h.streamLogsForContainers(w, r, func(container *docker.Container) bool {
|
||||
return container.State == "running" && container.Host == host
|
||||
})
|
||||
}
|
||||
|
||||
func (h *handler) streamLogsForContainers(w http.ResponseWriter, r *http.Request, containerFilter ContainerFilter) {
|
||||
var stdTypes docker.StdType
|
||||
if r.URL.Query().Has("stdout") {
|
||||
|
||||
@@ -94,6 +94,7 @@ func createRouter(h *handler) *chi.Mux {
|
||||
r.Use(auth.RequireAuthentication)
|
||||
}
|
||||
r.Get("/hosts/{host}/containers/{id}/logs/stream", h.streamContainerLogs)
|
||||
r.Get("/hosts/{host}/logs/stream", h.streamHostLogs)
|
||||
r.Get("/hosts/{host}/containers/{id}/logs", h.fetchLogsBetweenDates)
|
||||
r.Get("/hosts/{host}/logs/mergedStream/{ids}", h.streamLogsMerged)
|
||||
r.Get("/containers/{hostIds}/download", h.downloadLogs) // formatted as host:container,host:container
|
||||
|
||||
@@ -34,6 +34,7 @@ tooltip:
|
||||
pin-column: Pin as column
|
||||
merge-services: Merge all services into one view
|
||||
merge-containers: Merge all containers into one view
|
||||
merge-hosts: Merge all containers on this host into one view
|
||||
error:
|
||||
page-not-found: This page does not exist
|
||||
invalid-auth: Username or password are not valid
|
||||
|
||||