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

feat: adds a side panel for complex logs that enables reordering and enabling fields (#3237)

This commit is contained in:
Amir Raminfar
2024-08-28 17:37:04 -07:00
committed by GitHub
parent 62a2ef9eb4
commit 85271a595a
31 changed files with 345 additions and 174 deletions

View File

@@ -50,6 +50,7 @@ declare global {
const effectScope: typeof import('vue')['effectScope']
const extendRef: typeof import('@vueuse/core')['extendRef']
const flattenJSON: typeof import('./utils/index')['flattenJSON']
const flattenJSONToMap: typeof import('./utils/index')['flattenJSONToMap']
const formatBytes: typeof import('./utils/index')['formatBytes']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
@@ -104,6 +105,7 @@ declare global {
const persistentVisibleKeysForContainer: typeof import('./composable/storage')['persistentVisibleKeysForContainer']
const pinnedContainers: typeof import('./composable/storage')['pinnedContainers']
const provide: typeof import('vue')['provide']
const provideDetails: typeof import('./composable/showDetails')['provideDetails']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
const provideLogDetails: typeof import('./composable/showLogDetails')['provideLogDetails']
const provideLoggingContext: typeof import('./composable/logContext')['provideLoggingContext']
@@ -205,6 +207,7 @@ declare global {
const useDebounce: typeof import('@vueuse/core')['useDebounce']
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
const useDetails: typeof import('./composable/showDetails')['useDetails']
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
@@ -413,6 +416,7 @@ declare module 'vue' {
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly flattenJSON: UnwrapRef<typeof import('./utils/index')['flattenJSON']>
readonly flattenJSONToMap: UnwrapRef<typeof import('./utils/index')['flattenJSONToMap']>
readonly formatBytes: UnwrapRef<typeof import('./utils/index')['formatBytes']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
@@ -468,6 +472,7 @@ declare module 'vue' {
readonly pinnedContainers: UnwrapRef<typeof import('./composable/storage')['pinnedContainers']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
readonly provideLogDetails: UnwrapRef<typeof import('./composable/showLogDetails')['provideLogDetails']>
readonly provideLoggingContext: UnwrapRef<typeof import('./composable/logContext')['provideLoggingContext']>
readonly provideScrollContext: UnwrapRef<typeof import('./composable/scrollContext')['provideScrollContext']>
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
@@ -497,6 +502,7 @@ declare module 'vue' {
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly showAllContainers: UnwrapRef<typeof import('./stores/settings')['showAllContainers']>
readonly showLogDetails: UnwrapRef<typeof import('./composable/showLogDetails')['showLogDetails']>
readonly showStd: UnwrapRef<typeof import('./stores/settings')['showStd']>
readonly showTimestamp: UnwrapRef<typeof import('./stores/settings')['showTimestamp']>
readonly size: UnwrapRef<typeof import('./stores/settings')['size']>
@@ -607,6 +613,7 @@ declare module 'vue' {
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useLogDetails: UnwrapRef<typeof import('./composable/showLogDetails')['useLogDetails']>
readonly useLogSearchContext: UnwrapRef<typeof import('./composable/logSearchContext')['useLogSearchContext']>
readonly useLoggingContext: UnwrapRef<typeof import('./composable/logContext')['useLoggingContext']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
@@ -766,6 +773,7 @@ declare module '@vue/runtime-core' {
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly flattenJSON: UnwrapRef<typeof import('./utils/index')['flattenJSON']>
readonly flattenJSONToMap: UnwrapRef<typeof import('./utils/index')['flattenJSONToMap']>
readonly formatBytes: UnwrapRef<typeof import('./utils/index')['formatBytes']>
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
@@ -821,6 +829,7 @@ declare module '@vue/runtime-core' {
readonly pinnedContainers: UnwrapRef<typeof import('./composable/storage')['pinnedContainers']>
readonly provide: UnwrapRef<typeof import('vue')['provide']>
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
readonly provideLogDetails: UnwrapRef<typeof import('./composable/showLogDetails')['provideLogDetails']>
readonly provideLoggingContext: UnwrapRef<typeof import('./composable/logContext')['provideLoggingContext']>
readonly provideScrollContext: UnwrapRef<typeof import('./composable/scrollContext')['provideScrollContext']>
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
@@ -850,6 +859,7 @@ declare module '@vue/runtime-core' {
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly showAllContainers: UnwrapRef<typeof import('./stores/settings')['showAllContainers']>
readonly showLogDetails: UnwrapRef<typeof import('./composable/showLogDetails')['showLogDetails']>
readonly showStd: UnwrapRef<typeof import('./stores/settings')['showStd']>
readonly showTimestamp: UnwrapRef<typeof import('./stores/settings')['showTimestamp']>
readonly size: UnwrapRef<typeof import('./stores/settings')['size']>
@@ -960,6 +970,7 @@ declare module '@vue/runtime-core' {
readonly useKeyModifier: UnwrapRef<typeof import('@vueuse/core')['useKeyModifier']>
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useLogDetails: UnwrapRef<typeof import('./composable/showLogDetails')['useLogDetails']>
readonly useLogSearchContext: UnwrapRef<typeof import('./composable/logSearchContext')['useLogSearchContext']>
readonly useLoggingContext: UnwrapRef<typeof import('./composable/logContext')['useLoggingContext']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>

View File

@@ -49,6 +49,7 @@ declare module 'vue' {
LabeledInput: typeof import('./components/common/LabeledInput.vue')['default']
Links: typeof import('./components/Links.vue')['default']
LogDate: typeof import('./components/LogViewer/LogDate.vue')['default']
LogDetails: typeof import('./components/LogViewer/LogDetails.vue')['default']
LogLevel: typeof import('./components/LogViewer/LogLevel.vue')['default']
LogList: typeof import('./components/LogViewer/LogList.vue')['default']
LogMessageActions: typeof import('./components/LogViewer/LogMessageActions.vue')['default']
@@ -69,6 +70,7 @@ declare module 'vue' {
'Mdi:hexagonMultiple': typeof import('~icons/mdi/hexagon-multiple')['default']
'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default']
'Mdi:magnify': typeof import('~icons/mdi/magnify')['default']
'Mdi:mdi:close': typeof import('~icons/mdi/mdi')['default']
'Mdi:satelliteVariant': typeof import('~icons/mdi/satellite-variant')['default']
MobileMenu: typeof import('./components/common/MobileMenu.vue')['default']
MultiContainerActionToolbar: typeof import('./components/LogViewer/MultiContainerActionToolbar.vue')['default']
@@ -97,6 +99,7 @@ declare module 'vue' {
ScrollProgress: typeof import('./components/ScrollProgress.vue')['default']
Search: typeof import('./components/Search.vue')['default']
ServiceLog: typeof import('./components/ServiceViewer/ServiceLog.vue')['default']
SideDrawer: typeof import('./components/common/SideDrawer.vue')['default']
SideMenu: typeof import('./components/SideMenu.vue')['default']
SidePanel: typeof import('./components/SidePanel.vue')['default']
SimpleLogItem: typeof import('./components/LogViewer/SimpleLogItem.vue')['default']

View File

@@ -13,7 +13,12 @@
v-model="query"
:placeholder="$t('placeholder.search-containers')"
/>
<mdi:keyboard-esc class="flex" />
<form method="dialog">
<button class="swap swap-rotate hover:swap-active">
<mdi:keyboard-esc class="swap-off" />
<mdi:close class="swap-on" />
</button>
</form>
</div>
<div
class="dropdown-content !relative mt-2 max-h-[calc(100dvh-20rem)] w-full overflow-y-scroll rounded-md border-y-8 border-transparent bg-base-lighter px-2"

View File

@@ -16,7 +16,7 @@
ref="viewer"
:stream-source="useGroupedStream"
:entity="group"
:visible-keys="visibleKeys"
:visible-keys="new Map<string[], boolean>()"
:show-container-name="true"
/>
</template>
@@ -43,6 +43,5 @@ const { customGroups } = storeToRefs(swarmStore);
const group = computed(() => customGroups.value.find((g) => g.name === name) ?? new GroupedContainers("", []));
const visibleKeys = ref<string[][]>([]);
provideLoggingContext(toRef(() => group.value.containers));
</script>

View File

@@ -1,10 +1,5 @@
<template>
<div class="group/item relative flex w-full gap-x-2" @click="expandToggle()">
<label class="swap swap-rotate invisible absolute -left-4 top-0.5 size-4 group-hover/item:visible">
<input type="checkbox" v-model="expanded" @click="expandToggle()" />
<material-symbols:expand-all-rounded class="swap-off text-secondary" />
<material-symbols:collapse-all-rounded class="swap-on text-secondary" />
</label>
<div class="group/item relative flex w-full gap-x-2 hover:bg-secondary/10" @click="showLogDetails(logEntry)">
<div v-if="showContainerName">
<ContainerName :id="logEntry.containerID" />
</div>
@@ -18,7 +13,7 @@
<LogLevel :level="logEntry.level" />
</div>
<div>
<ul class="fields cursor-pointer space-x-4" :class="{ expanded }">
<ul class="fields cursor-pointer space-x-4">
<li v-for="(value, name) in validValues" :key="name">
<span class="text-light">{{ name }}=</span><span class="font-bold" v-if="value === null">&lt;null&gt;</span>
<template v-else-if="Array.isArray(value)">
@@ -28,7 +23,6 @@
</li>
<li class="text-light" v-if="Object.keys(validValues).length === 0">all values are hidden</li>
</ul>
<FieldList :fields="logEntry.unfilteredMessage" :expanded="expanded" :visible-keys="visibleKeys" />
</div>
<LogMessageActions
class="duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100"
@@ -44,15 +38,14 @@ const { markSearch } = useSearchFilter();
const { logEntry, showContainerName = false } = defineProps<{
logEntry: ComplexLogEntry;
visibleKeys: string[][];
showContainerName?: boolean;
}>();
const [expanded, expandToggle] = useToggle();
const validValues = computed(() => {
return Object.fromEntries(Object.entries(logEntry.message).filter(([_, value]) => value !== undefined));
});
const showLogDetails = useLogDetails();
</script>
<style lang="postcss" scoped>

View File

@@ -1,74 +0,0 @@
<template>
<ul v-if="expanded" ref="root" class="ml-8">
<li v-for="(value, name) in fields" :key="name">
<template v-if="isObject(value)">
<span class="text-light">{{ name }}=</span>
<FieldList :fields="value" :parent-key="parentKey.concat(name)" :visible-keys="visibleKeys" expanded />
</template>
<template v-else-if="Array.isArray(value)">
<a @click.stop="toggleField(name)" class="link-primary mr-2 cursor-pointer">
{{ hasField(name) ? "remove" : "add" }}
</a>
<span class="text-light">{{ name }}=</span>[
<span class="font-bold" v-for="(item, index) in value">
<span v-html="JSON.stringify(item)"></span><span v-if="index !== value.length - 1">,</span>
</span>
]
</template>
<template v-else>
<a @click.stop="toggleField(name)" class="link-primary mr-2 cursor-pointer">
{{ hasField(name) ? "remove" : "add" }}
</a>
<span class="text-light">{{ name }}=</span><span class="font-bold" v-html="value"></span>
</template>
</li>
</ul>
</template>
<script lang="ts" setup>
import { arrayEquals, isObject } from "@/utils";
const {
fields,
expanded = false,
parentKey = [],
visibleKeys = [],
} = defineProps<{
fields: Record<string, any>;
expanded?: boolean;
parentKey?: string[];
visibleKeys?: string[][];
}>();
const root = ref<HTMLElement>();
async function toggleField(field: string) {
const index = fieldIndex(field);
if (index > -1) {
visibleKeys.splice(index, 1);
} else {
visibleKeys.push(parentKey.concat(field));
}
await nextTick();
root.value?.scrollIntoView({
block: "center",
});
}
function hasField(field: string) {
return fieldIndex(field) > -1;
}
function fieldIndex(field: string) {
const path = parentKey.concat(field);
return visibleKeys.findIndex((keys) => arrayEquals(toRaw(keys), toRaw(path)));
}
</script>
<style scoped lang="postcss">
.text-light {
@apply text-base-content/70;
}
</style>

View File

@@ -0,0 +1,119 @@
<template>
<header class="flex items-center gap-4">
<Tag :data-level="entry.level" class="uppercase text-white">{{ entry.level }}</Tag>
<h1 class="text-lg">
<DateTime :date="entry.date" />
</h1>
<h2 class="text-sm"><DistanceTime :date="entry.date" /> on {{ entry.std }}</h2>
</header>
<div class="mt-8 flex flex-col gap-4">
<section class="grid grid-cols-3 gap-2">
<div>
<div class="font-thin">Container Name</div>
<div class="text-lg font-bold">{{ container.name }}</div>
</div>
<div>
<div class="font-thin">Host</div>
<div class="text-lg font-bold">
{{ hosts[container.host].name }}
</div>
</div>
<div>
<div class="font-thin">Image</div>
<div class="text-lg font-bold">{{ container.image }}</div>
</div>
</section>
<table class="table" v-if="entry instanceof ComplexLogEntry">
<thead class="text-lg">
<tr>
<th>Field</th>
<th>Value</th>
<th>Show</th>
</tr>
</thead>
<tbody ref="list">
<tr v-for="{ key, value, enabled } in fields" :key="key.join('.')" class="hover">
<td class="cursor-move font-mono">
{{ key.join(".") }}
</td>
<td>
<code v-html="JSON.stringify(value)"></code>
</td>
<td>
<input type="checkbox" class="toggle toggle-primary" :checked="enabled" @change="toggleField(key)" />
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { ComplexLogEntry } from "@/models/LogEntry";
import { useSortable } from "@vueuse/integrations/useSortable";
import DistanceTime from "../common/DistanceTime.vue";
const { entry } = defineProps<{ entry: ComplexLogEntry }>();
const { currentContainer } = useContainerStore();
const list = ref<HTMLElement>();
const container = currentContainer(toRef(() => entry.containerID));
const visibleKeys = persistentVisibleKeysForContainer(container);
const { hosts } = useHosts();
function toggleField(key: string[]) {
if (visibleKeys.value.size === 0) {
visibleKeys.value = new Map<string[], boolean>(fields.value.map(({ key }) => [key, true]));
}
const enabled = visibleKeys.value.get(key);
visibleKeys.value.set(key, !enabled);
}
const fields = computed({
get() {
const fieldsWithValue: { key: string[]; value: any; enabled: boolean }[] = [];
const allFields = flattenJSONToMap(entry.unfilteredMessage);
if (visibleKeys.value.size === 0) {
for (const [key, value] of allFields) {
fieldsWithValue.push({ key, value, enabled: true });
}
} else {
for (const [key, enabled] of visibleKeys.value) {
const value = getDeep(entry.unfilteredMessage, key);
fieldsWithValue.push({ key, value, enabled });
}
for (const [key, value] of allFields) {
if ([...visibleKeys.value.keys()].findIndex((k) => arrayEquals(k, key)) === -1) {
fieldsWithValue.push({ key, value, enabled: false });
}
}
}
return fieldsWithValue;
},
set(value) {
const map = new Map<string[], boolean>();
for (const { key, enabled } of value) {
map.set(key, enabled);
}
visibleKeys.value = map;
},
});
useSortable(list, fields);
</script>
<style lang="postcss" scoped>
.font-mono {
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Consolas,
Liberation Mono,
monaco,
Menlo,
monospace;
}
</style>

View File

@@ -32,25 +32,26 @@ defineProps<{
margin-top: -0.4em;
align-self: flex-start;
}
</style>
<style lang="postcss">
[data-level="debug"],
[data-level="trace"] {
@apply bg-purple;
@apply !bg-purple;
}
[data-level="info"] {
@apply bg-green;
@apply !bg-green;
}
[data-level="error"],
[data-level="severe"],
[data-level="critical"],
[data-level="fatal"] {
@apply bg-red;
@apply !bg-red;
}
[data-level="warn"],
[data-level="warning"] {
@apply bg-orange;
@apply !bg-orange;
}
</style>

View File

@@ -13,12 +13,7 @@
:class="{ 'border border-secondary': toRaw(item) === toRaw(lastSelectedItem) }"
class="group/entry"
>
<component
:is="item.getComponent()"
:log-entry="item"
:visible-keys="visibleKeys"
:show-container-name="showContainerName"
/>
<component :is="item.getComponent()" :log-entry="item" :show-container-name="showContainerName" />
</li>
</ul>
</template>
@@ -30,7 +25,6 @@ const { loading, progress, currentDate } = useScrollContext();
const { messages } = defineProps<{
messages: LogEntry<string | JSONObject>[];
visibleKeys: string[][];
lastSelectedItem: LogEntry<string | JSONObject> | undefined;
showContainerName: boolean;
}>();

View File

@@ -7,7 +7,7 @@
>
<span
class="rounded bg-slate-800/60 px-1.5 py-1 text-primary hover:bg-slate-700"
@click="copyLogMessageToClipBoard()"
@click.prevent="copyLogMessageToClipBoard()"
>
<carbon:copy-file />
</span>
@@ -19,7 +19,7 @@
>
<a
class="rounded bg-slate-800/60 px-1.5 py-1 text-primary hover:bg-slate-700"
@click="handleJumpLineSelected($event, logEntry)"
@click.prevent="handleJumpLineSelected($event, logEntry)"
:href="`#${logEntry.id}`"
>
<carbon:search-locate />

View File

@@ -1,20 +1,18 @@
<template>
<LogList
:messages="filtered"
:last-selected-item="lastSelectedItem"
:visible-keys="visibleKeys"
:show-container-name="showContainerName"
/>
<SideDrawer ref="drawer">
<LogDetails :entry="entry" v-if="entry && entry instanceof ComplexLogEntry" />
</SideDrawer>
<LogList :messages="filtered" :last-selected-item="lastSelectedItem" :show-container-name="showContainerName" />
</template>
<script lang="ts" setup>
import { useRouteHash } from "@vueuse/router";
import { type JSONObject, LogEntry } from "@/models/LogEntry";
import SideDrawer from "@/components/common/SideDrawer.vue";
import { ComplexLogEntry, type JSONObject, LogEntry } from "@/models/LogEntry";
const props = defineProps<{
messages: LogEntry<string | JSONObject>[];
visibleKeys: string[][];
visibleKeys: Map<string[], boolean>;
showContainerName: boolean;
}>();
@@ -23,6 +21,10 @@ const { messages, visibleKeys } = toRefs(props);
const { filteredPayload } = useVisibleFilter(visibleKeys);
const { filteredMessages } = useSearchFilter();
const drawer = ref<InstanceType<typeof SideDrawer>>() as Ref<InstanceType<typeof SideDrawer>>;
const { entry } = provideLogDetails(drawer);
const visible = filteredPayload(messages);
const filtered = filteredMessages(visible);

View File

@@ -10,7 +10,7 @@ import { LogStreamSource } from "@/composable/eventStreams";
const { streamSource, visibleKeys, showContainerName, entity } = defineProps<{
streamSource: (t: Ref<T>) => LogStreamSource;
visibleKeys: string[][];
visibleKeys: Map<string[], boolean>;
showContainerName: boolean;
entity: T;
}>();

View File

@@ -3,7 +3,7 @@
exports[`<ContainerEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
"<ul data-v-cf9ff940="" class="events group pt-4 medium">
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2">
<!--v-if-->
<!--v-if-->
<div data-v-961504e7="" data-v-a49e52d4="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] select-none" size="small">
@@ -23,7 +23,7 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
exports[`<ContainerEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
"<ul data-v-cf9ff940="" class="events group pt-4 medium">
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2">
<!--v-if-->
<!--v-if-->
<div data-v-961504e7="" data-v-a49e52d4="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] select-none" size="small">
@@ -43,7 +43,7 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
exports[`<ContainerEventSource /> > render html correctly > should render messages 1`] = `
"<ul data-v-cf9ff940="" class="events group pt-4 medium">
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2">
<!--v-if-->
<!--v-if-->
<div data-v-961504e7="" data-v-a49e52d4="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] select-none" size="small">
@@ -63,7 +63,7 @@ exports[`<ContainerEventSource /> > render html correctly > should render messag
exports[`<ContainerEventSource /> > render html correctly > should render messages with filter 1`] = `
"<ul data-v-cf9ff940="" class="events group pt-4 medium">
<li data-v-cf9ff940="" data-key="2" data-time="1560336942459" class="group/entry">
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2">
<!--v-if-->
<!--v-if-->
<div data-v-961504e7="" data-v-a49e52d4="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] select-none" size="small">
@@ -85,7 +85,7 @@ exports[`<ContainerEventSource /> > render html correctly > should render messag
exports[`<ContainerEventSource /> > render html correctly > should render messages with html entities 1`] = `
"<ul data-v-cf9ff940="" class="events group pt-4 medium">
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2" visible-keys="">
<div data-v-a49e52d4="" data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2">
<!--v-if-->
<!--v-if-->
<div data-v-961504e7="" data-v-a49e52d4="" class="inline-flex items-center justify-center rounded bg-base-lighter px-2 py-[0.2em] select-none" size="small">
@@ -104,6 +104,16 @@ exports[`<ContainerEventSource /> > render html correctly > should render messag
exports[`<ContainerEventSource /> > renders loading correctly 1`] = `
"<div class="flex min-h-[1px] justify-center"><span class="loading loading-bars loading-md mt-4 text-primary" style="display: none;"></span></div>
<dialog data-v-43493931="" class="modal-right modal items-start outline-none backdrop:bg-none">
<div data-v-43493931="" class="modal-box">
<form data-v-43493931="" method="dialog"><button data-v-43493931="" class="swap swap-rotate absolute right-4 top-4 hover:swap-active"><svg data-v-43493931="" viewBox="0 0 24 24" width="1.2em" height="1.2em" class="swap-off">
<path fill="currentColor" d="M1 7h6v2H3v2h4v2H3v2h4v2H1zm10 0h4v2h-4v2h2a2 2 0 0 1 2 2v2c0 1.11-.89 2-2 2H9v-2h4v-2h-2a2 2 0 0 1-2-2V9c0-1.1.9-2 2-2m8 0h2a2 2 0 0 1 2 2v1h-2V9h-2v6h2v-1h2v1c0 1.11-.89 2-2 2h-2a2 2 0 0 1-2-2V9c0-1.1.9-2 2-2"></path>
</svg><svg data-v-43493931="" viewBox="0 0 24 24" width="1.2em" height="1.2em" class="swap-on">
<path fill="currentColor" d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z"></path>
</svg></button></form>
</div>
<form data-v-43493931="" method="dialog" class="modal-backdrop"><button data-v-43493931="">close</button></form>
</dialog>
<!--v-if-->"
`;

View File

@@ -15,7 +15,7 @@
ref="viewer"
:stream-source="useMergedStream"
:entity="containers"
:visible-keys="visibleKeys"
:visible-keys="new Map<string[], boolean>()"
:show-container-name="true"
/>
</template>
@@ -35,8 +35,5 @@ const containerStore = useContainerStore();
const { allContainersById, ready } = storeToRefs(containerStore);
const containers = computed(() => ids.map((id) => allContainersById.value[id]));
const visibleKeys = ref<string[][]>([]);
provideLoggingContext(containers);
</script>

View File

@@ -25,7 +25,7 @@
</div>
</div>
<div
class="animate-background h-1 bg-gradient-to-br from-primary via-transparent to-primary"
class="animate-background h-1 bg-gradient-to-br from-primary via-primary/20 to-primary"
v-show="!scrollContext.paused && !scrollContext.loading"
></div>
<div ref="scrollObserver" class="h-px"></div>
@@ -96,7 +96,7 @@ function scrollToBottom(behavior: "auto" | "smooth" = "auto") {
.animate-background {
background-size: 400% 400%;
animation: gradient-animation 4s ease infinite;
animation: gradient-animation 5s ease infinite;
}
@keyframes gradient-animation {

View File

@@ -19,7 +19,7 @@
ref="viewer"
:stream-source="useServiceStream"
:entity="service"
:visible-keys="visibleKeys"
:visible-keys="new Map<string[], boolean>()"
:show-container-name="true"
/>
</template>
@@ -36,7 +36,6 @@ const { name, scrollable = false } = defineProps<{
name: string;
}>();
const visibleKeys = ref<string[][]>([]);
const viewer = ref<ComponentExposed<typeof ViewerWithSource>>();
const store = useSwarmStore();
const { services } = storeToRefs(store) as unknown as { services: Ref<Service[]> };

View File

@@ -22,7 +22,7 @@
ref="viewer"
:stream-source="useStackStream"
:entity="stack"
:visible-keys="visibleKeys"
:visible-keys="new Map<string[], boolean>()"
:show-container-name="true"
/>
</template>
@@ -38,7 +38,6 @@ const { name, scrollable = false } = defineProps<{
name: string;
}>();
const visibleKeys = ref<string[][]>([]);
const viewer = ref<ComponentExposed<typeof ViewerWithSource>>();
const store = useSwarmStore();
const { stacks } = storeToRefs(store) as unknown as { stacks: Ref<Stack[]> };

View File

@@ -0,0 +1,34 @@
<template>
<dialog ref="panel" class="modal-right modal items-start outline-none backdrop:bg-none">
<div class="modal-box">
<form method="dialog">
<button class="swap swap-rotate absolute right-4 top-4 hover:swap-active">
<mdi:keyboard-esc class="swap-off" />
<mdi:close class="swap-on" />
</button>
</form>
<slot></slot>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</template>
<script setup lang="ts">
const panel = ref<HTMLDialogElement>();
defineExpose({
open: () => {
panel.value?.showModal();
},
});
</script>
<style scoped lang="postcss">
.modal-right :where(.modal-box) {
@apply fixed right-0 h-lvh max-h-screen max-w-3xl translate-x-24 scale-100 rounded-none bg-base-lighter shadow-none;
}
.modal-right[open] .modal-box {
@apply translate-x-0;
}
</style>

View File

@@ -1,14 +1,34 @@
import { Profile } from "@/stores/config";
export function useProfileStorage<K extends keyof Profile>(key: K, defaultValue: NonNullable<Profile[K]>) {
interface SerializerTransformer<T, U> {
from: (raw: U) => T;
to: (value: T) => U;
}
export function useProfileStorage<K extends keyof Profile>(
key: K,
defaultValue: NonNullable<Profile[K]>,
transformer?: SerializerTransformer<NonNullable<Profile[K]>, any>,
) {
const storageKey = "DOZZLE_" + key.toUpperCase();
const storage = useStorage<NonNullable<Profile[K]>>(storageKey, defaultValue, undefined, {
writeDefaults: false,
mergeDefaults: true,
serializer: transformer
? {
read: (raw) => transformer.from(JSON.parse(raw)),
write: (value) => JSON.stringify(transformer.to(value)),
}
: undefined,
onError: (e) => {
console.error(`Failed to read ${storageKey} from storage`, e);
},
});
if (config.profile?.[key]) {
if (storage.value instanceof Set && config.profile[key] instanceof Array) {
if (transformer) {
storage.value = transformer.from(config.profile[key]);
} else if (storage.value instanceof Set && config.profile[key] instanceof Array) {
storage.value = new Set([...(config.profile[key] as Iterable<any>)]) as unknown as NonNullable<Profile[K]>;
} else if (config.profile[key] instanceof Array) {
storage.value = config.profile[key] as NonNullable<Profile[K]>;
@@ -23,9 +43,19 @@ export function useProfileStorage<K extends keyof Profile>(key: K, defaultValue:
watch(
storage,
(value) => {
const transformedValue = transformer ? transformer.to(value) : value;
fetch(withBase("/api/profile"), {
method: "PATCH",
body: JSON.stringify({ [key]: value }, (_, value) => (value instanceof Set ? [...value] : value)),
body: JSON.stringify({ [key]: transformedValue }, (_, value) => {
if (value instanceof Set) {
return Array.from(value);
} else if (value instanceof Map) {
return Array.from(value.entries());
} else {
return value;
}
}),
});
},
{ deep: true },

View File

@@ -0,0 +1,22 @@
import { JSONObject, LogEntry } from "@/models/LogEntry";
import SideDrawer from "@/components/common/SideDrawer.vue";
export const showLogDetails = Symbol("showLogDetails") as InjectionKey<
(logEntry: LogEntry<string | JSONObject>) => void
>;
export const provideLogDetails = (drawer: Ref<InstanceType<typeof SideDrawer>>) => {
const entry = ref<LogEntry<string | JSONObject>>();
provide(showLogDetails, (logEntry: LogEntry<string | JSONObject>) => {
entry.value = logEntry;
drawer.value?.open();
});
return { entry };
};
export const useLogDetails = () => {
const showDetails = inject(showLogDetails, () => {});
return showDetails;
};

View File

@@ -7,17 +7,21 @@ if (config.hosts.length === 1 && !sessionHost.value) {
sessionHost.value = config.hosts[0].id;
}
export function persistentVisibleKeysForContainer(container: Ref<Container>): Ref<string[][]> {
const storage = useProfileStorage("visibleKeys", {});
return computed(() => {
if (!(container.value.storageKey in storage.value)) {
// Returning a temporary ref here to avoid writing an empty array to storage
const visibleKeys = ref<string[][]>([]);
watchOnce(visibleKeys, () => (storage.value[container.value.storageKey] = visibleKeys.value), { deep: true });
return visibleKeys.value;
}
return storage.value[container.value.storageKey];
const storage = useProfileStorage("visibleKeys", new Map<string, Map<string[], boolean>>(), {
from(transformed: [string, [string[], boolean][]][]) {
return new Map(transformed.map(([key, value]) => [key, new Map(value)]));
},
to(value: Map<string, Map<string[], boolean>>) {
const outer = Array.from(value.entries());
const inner = outer.map(([key, value]) => [key, Array.from(value.entries())]);
return inner;
},
});
export function persistentVisibleKeysForContainer(container: Ref<Container>): Ref<Map<string[], boolean>> {
// Computed property to only store to storage when the value changes
return computed({
get: () => storage.value.get(container.value.storageKey) || new Map<string[], boolean>(),
set: (value: Map<string[], boolean>) => storage.value.set(container.value.storageKey, value),
});
}

View File

@@ -1,7 +1,7 @@
import { ComplexLogEntry, type JSONObject, type LogEntry } from "@/models/LogEntry";
import type { Ref } from "vue";
export function useVisibleFilter(visibleKeys: Ref<string[][]>) {
export function useVisibleFilter(visibleKeys: Ref<Map<string[], boolean>>) {
function filteredPayload(messages: Ref<LogEntry<string | JSONObject>[]>) {
return computed(() => {
return messages.value.map((d) => {

View File

@@ -67,15 +67,18 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
date: Date,
public readonly level: Level,
public readonly std: Std,
visibleKeys?: Ref<string[][]>,
visibleKeys?: Ref<Map<string[], boolean>>,
) {
super(message, containerID, id, date, std, level);
if (visibleKeys) {
this.filteredMessage = computed(() => {
if (!visibleKeys.value.length) {
if (visibleKeys.value.size === 0) {
return flattenJSON(message);
} else {
return visibleKeys.value.reduce((acc, attr) => ({ ...acc, [attr.join(".")]: getDeep(message, attr) }), {});
const keys = Array.from(visibleKeys.value.entries())
.filter(([, value]) => value)
.map(([key]) => key);
return keys.reduce((acc, attr) => ({ ...acc, [attr.join(".")]: getDeep(message, attr) }), {});
}
});
} else {
@@ -87,14 +90,14 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
}
public get message(): JSONObject {
return this.filteredMessage.value;
return unref(this.filteredMessage);
}
public get unfilteredMessage(): JSONObject {
return this._message;
}
static fromLogEvent(event: ComplexLogEntry, visibleKeys: Ref<string[][]>): ComplexLogEntry {
static fromLogEvent(event: ComplexLogEntry, visibleKeys: Ref<Map<string[], boolean>>): ComplexLogEntry {
return new ComplexLogEntry(
event._message,
event.containerID,
@@ -165,7 +168,7 @@ export function asLogEntry(event: LogEvent): LogEntry<string | JSONObject> {
event.id,
new Date(event.ts),
event.l,
event.s === "unknown" ? "stderr" : event.s ?? "stderr",
event.s === "unknown" ? "stderr" : (event.s ?? "stderr"),
);
} else {
return new SimpleLogEntry(
@@ -175,7 +178,7 @@ export function asLogEntry(event: LogEvent): LogEntry<string | JSONObject> {
new Date(event.ts),
event.l,
event.p,
event.s === "unknown" ? "stderr" : event.s ?? "stderr",
event.s === "unknown" ? "stderr" : (event.s ?? "stderr"),
);
}
}

View File

@@ -108,7 +108,6 @@
</div>
<LogList
:messages="fakeMessages"
:visible-keys="keys"
:last-selected-item="undefined"
:show-container-name="false"
class="hidden overflow-hidden rounded-lg border border-base-content/50 shadow @3xl:block"
@@ -163,7 +162,6 @@ const { t } = useI18n();
setTitle(t("title.settings"));
const { latest, hasUpdate } = useReleases();
const keys = ref<string[][]>([]);
const now = new Date();
const hoursAgo = (hours: number) => {
const date = new Date(now);
@@ -208,7 +206,6 @@ const fakeMessages = computedWithControl(
new Date(),
"info",
"stdout",
keys,
),
new SimpleLogEntry(t("settings.log.simple"), "123", 7, new Date(), "debug", undefined, "stderr"),
],

View File

@@ -22,7 +22,7 @@ export interface Config {
export interface Profile {
settings?: Settings;
pinned?: Set<string>;
visibleKeys?: { [key: string]: string[][] };
visibleKeys?: Map<string, Map<string[], boolean>>;
releaseSeen?: string;
collapsedGroups?: Set<string>;
}

View File

@@ -16,16 +16,28 @@ export function isObject(value: any): value is Record<string, any> {
}
export function flattenJSON(obj: Record<string, any>, path: string[] = []) {
const result: Record<string, any> = {};
Object.keys(obj).forEach((key) => {
const map = flattenJSONToMap(obj);
const result = {} as Record<string, any>;
for (const [key, value] of map) {
result[key.join(".")] = value;
}
return result;
}
export function flattenJSONToMap(obj: Record<string, any>, path: string[] = []): Map<string[], any> {
const result = new Map<string[], any>();
for (const key of Object.keys(obj)) {
const value = obj[key];
const newPath = path.concat(key);
if (isObject(value)) {
Object.assign(result, flattenJSON(value, newPath));
for (const [k, v] of flattenJSONToMap(value, newPath)) {
result.set(k, v);
}
} else {
result[newPath.join(".")] = value;
result.set(newPath, value);
}
});
}
return result;
}

View File

@@ -38,11 +38,11 @@ type Settings struct {
}
type Profile struct {
Settings *Settings `json:"settings,omitempty"`
Pinned []string `json:"pinned"`
VisibleKeys map[string][][]string `json:"visibleKeys,omitempty"`
ReleaseSeen string `json:"releaseSeen,omitempty"`
CollapsedGroups []string `json:"collapsedGroups"`
Settings *Settings `json:"settings,omitempty"`
Pinned []string `json:"pinned"`
VisibleKeys []interface{} `json:"visibleKeys,omitempty"`
ReleaseSeen string `json:"releaseSeen,omitempty"`
CollapsedGroups []string `json:"collapsedGroups"`
}
var dataPath string
@@ -68,7 +68,7 @@ func UpdateFromReader(user auth.User, reader io.Reader) error {
defer mux.Unlock()
existingProfile, err := Load(user)
if err != nil && err != errMissingProfileErr {
return err
log.Error().Err(err).Msg("Unable to load profile. Overwriting it.")
}
if err := json.NewDecoder(reader).Decode(&existingProfile); err != nil {

View File

@@ -27,6 +27,7 @@ func Test_createRoutes_index(t *testing.T) {
abide.AssertHTTPResponse(t, t.Name(), rr.Result())
}
func Test_createRoutes_redirect(t *testing.T) {
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, "index.html", []byte("index page"), 0644), "WriteFile should have no error.")

View File

@@ -40,7 +40,7 @@ error:
events-stream:
title: Unexpected Error
message: >-
Dozzle UI wasn't able to connect API. Please check your network settings.
Dozzle UI wasn't able to connect to API. Please check your network settings.
If you are using a reverse proxy, please make sure it is configured
properly.
events-timeout:

View File

@@ -59,6 +59,7 @@
"lodash.debounce": "^4.0.8",
"pinia": "^2.2.2",
"postcss": "^8.4.41",
"sortablejs": "^1.15.2",
"splitpanes": "^3.1.5",
"strip-ansi": "^7.1.0",
"tailwindcss": "^3.4.10",
@@ -85,7 +86,7 @@
"@types/d3-shape": "^3.1.6",
"@types/d3-transition": "^3.0.8",
"@types/lodash.debounce": "^4.0.9",
"@types/node": "^22.5.1",
"@types/node": "^22.5.0",
"@vitejs/plugin-vue": "5.1.2",
"@vue/compiler-sfc": "^3.4.38",
"@vue/test-utils": "^2.4.6",

21
pnpm-lock.yaml generated
View File

@@ -49,7 +49,7 @@ importers:
version: 11.0.3(vue@3.4.38(typescript@5.5.4))
'@vueuse/integrations':
specifier: ^11.0.3
version: 11.0.3(focus-trap@7.5.4)(fuse.js@7.0.0)(vue@3.4.38(typescript@5.5.4))
version: 11.0.3(focus-trap@7.5.4)(fuse.js@7.0.0)(sortablejs@1.15.2)(vue@3.4.38(typescript@5.5.4))
'@vueuse/router':
specifier: ^11.0.3
version: 11.0.3(vue-router@4.4.3(vue@3.4.38(typescript@5.5.4)))(vue@3.4.38(typescript@5.5.4))
@@ -98,6 +98,9 @@ importers:
postcss:
specifier: ^8.4.41
version: 8.4.41
sortablejs:
specifier: ^1.15.2
version: 1.15.2
splitpanes:
specifier: ^3.1.5
version: 3.1.5
@@ -133,7 +136,7 @@ importers:
version: 0.11.0(vite@5.4.2(@types/node@22.5.1))(vue-router@4.4.3(vue@3.4.38(typescript@5.5.4)))(vue@3.4.38(typescript@5.5.4))
vitepress:
specifier: 1.3.4
version: 1.3.4(@algolia/client-search@5.0.0)(@types/node@22.5.1)(fuse.js@7.0.0)(postcss@8.4.41)(search-insights@2.16.3)(typescript@5.5.4)
version: 1.3.4(@algolia/client-search@5.0.0)(@types/node@22.5.1)(fuse.js@7.0.0)(postcss@8.4.41)(search-insights@2.16.3)(sortablejs@1.15.2)(typescript@5.5.4)
vue:
specifier: ^3.4.38
version: 3.4.38(typescript@5.5.4)
@@ -172,7 +175,7 @@ importers:
specifier: ^4.0.9
version: 4.0.9
'@types/node':
specifier: ^22.5.1
specifier: ^22.5.0
version: 22.5.1
'@vitejs/plugin-vue':
specifier: 5.1.2
@@ -2661,6 +2664,9 @@ packages:
resolution: {integrity: sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==}
engines: {node: '>=18'}
sortablejs@1.15.2:
resolution: {integrity: sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA==}
source-map-js@1.2.0:
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
engines: {node: '>=0.10.0'}
@@ -4305,7 +4311,7 @@ snapshots:
- '@vue/composition-api'
- vue
'@vueuse/integrations@11.0.3(focus-trap@7.5.4)(fuse.js@7.0.0)(vue@3.4.38(typescript@5.5.4))':
'@vueuse/integrations@11.0.3(focus-trap@7.5.4)(fuse.js@7.0.0)(sortablejs@1.15.2)(vue@3.4.38(typescript@5.5.4))':
dependencies:
'@vueuse/core': 11.0.3(vue@3.4.38(typescript@5.5.4))
'@vueuse/shared': 11.0.3(vue@3.4.38(typescript@5.5.4))
@@ -4313,6 +4319,7 @@ snapshots:
optionalDependencies:
focus-trap: 7.5.4
fuse.js: 7.0.0
sortablejs: 1.15.2
transitivePeerDependencies:
- '@vue/composition-api'
- vue
@@ -5638,6 +5645,8 @@ snapshots:
ansi-styles: 6.2.1
is-fullwidth-code-point: 5.0.0
sortablejs@1.15.2: {}
source-map-js@1.2.0: {}
source-map@0.6.1:
@@ -6098,7 +6107,7 @@ snapshots:
'@types/node': 22.5.1
fsevents: 2.3.3
vitepress@1.3.4(@algolia/client-search@5.0.0)(@types/node@22.5.1)(fuse.js@7.0.0)(postcss@8.4.41)(search-insights@2.16.3)(typescript@5.5.4):
vitepress@1.3.4(@algolia/client-search@5.0.0)(@types/node@22.5.1)(fuse.js@7.0.0)(postcss@8.4.41)(search-insights@2.16.3)(sortablejs@1.15.2)(typescript@5.5.4):
dependencies:
'@docsearch/css': 3.6.1
'@docsearch/js': 3.6.1(@algolia/client-search@5.0.0)(search-insights@2.16.3)
@@ -6109,7 +6118,7 @@ snapshots:
'@vue/devtools-api': 7.3.8
'@vue/shared': 3.4.38
'@vueuse/core': 11.0.3(vue@3.4.38(typescript@5.5.4))
'@vueuse/integrations': 11.0.3(focus-trap@7.5.4)(fuse.js@7.0.0)(vue@3.4.38(typescript@5.5.4))
'@vueuse/integrations': 11.0.3(focus-trap@7.5.4)(fuse.js@7.0.0)(sortablejs@1.15.2)(vue@3.4.38(typescript@5.5.4))
focus-trap: 7.5.4
mark.js: 8.11.1
minisearch: 7.1.0