1
0
mirror of https://github.com/amir20/dozzle.git synced 2025-12-24 06:28:42 +01:00

feat!: moves search to backend for better experience. It does remove show search results in context. (#3245)

This commit is contained in:
Amir Raminfar
2024-09-03 08:02:09 -07:00
committed by GitHub
parent 057d7ad712
commit 9f2f8b8245
32 changed files with 364 additions and 329 deletions

View File

@@ -614,7 +614,6 @@ declare module 'vue' {
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']> readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']> readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useLogDetails: UnwrapRef<typeof import('./composable/showLogDetails')['useLogDetails']> 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 useLoggingContext: UnwrapRef<typeof import('./composable/logContext')['useLoggingContext']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']> readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']> readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
@@ -971,7 +970,6 @@ declare module '@vue/runtime-core' {
readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']> readonly useLastChanged: UnwrapRef<typeof import('@vueuse/core')['useLastChanged']>
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']> readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
readonly useLogDetails: UnwrapRef<typeof import('./composable/showLogDetails')['useLogDetails']> 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 useLoggingContext: UnwrapRef<typeof import('./composable/logContext')['useLoggingContext']>
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']> readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']> readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>

View File

@@ -14,7 +14,6 @@ declare module 'vue' {
'Carbon:macShift': typeof import('~icons/carbon/mac-shift')['default'] 'Carbon:macShift': typeof import('~icons/carbon/mac-shift')['default']
'Carbon:play': typeof import('~icons/carbon/play')['default'] 'Carbon:play': typeof import('~icons/carbon/play')['default']
'Carbon:restart': typeof import('~icons/carbon/restart')['default'] 'Carbon:restart': typeof import('~icons/carbon/restart')['default']
'Carbon:searchLocate': typeof import('~icons/carbon/search-locate')['default']
'Carbon:star': typeof import('~icons/carbon/star')['default'] 'Carbon:star': typeof import('~icons/carbon/star')['default']
'Carbon:starFilled': typeof import('~icons/carbon/star-filled')['default'] 'Carbon:starFilled': typeof import('~icons/carbon/star-filled')['default']
'Carbon:stopFilledAlt': typeof import('~icons/carbon/stop-filled-alt')['default'] 'Carbon:stopFilledAlt': typeof import('~icons/carbon/stop-filled-alt')['default']
@@ -37,7 +36,6 @@ declare module 'vue' {
Dropdown: typeof import('./components/common/Dropdown.vue')['default'] Dropdown: typeof import('./components/common/Dropdown.vue')['default']
DropdownMenu: typeof import('./components/common/DropdownMenu.vue')['default'] DropdownMenu: typeof import('./components/common/DropdownMenu.vue')['default']
EventSource: typeof import('./components/LogViewer/EventSource.vue')['default'] EventSource: typeof import('./components/LogViewer/EventSource.vue')['default']
FieldList: typeof import('./components/LogViewer/FieldList.vue')['default']
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default'] FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default'] GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default']
HostIcon: typeof import('./components/common/HostIcon.vue')['default'] HostIcon: typeof import('./components/common/HostIcon.vue')['default']
@@ -55,8 +53,6 @@ declare module 'vue' {
LogMessageActions: typeof import('./components/LogViewer/LogMessageActions.vue')['default'] LogMessageActions: typeof import('./components/LogViewer/LogMessageActions.vue')['default']
LogStd: typeof import('./components/LogViewer/LogStd.vue')['default'] LogStd: typeof import('./components/LogViewer/LogStd.vue')['default']
LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default'] LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default']
'MaterialSymbols:collapseAllRounded': typeof import('~icons/material-symbols/collapse-all-rounded')['default']
'MaterialSymbols:expandAllRounded': typeof import('~icons/material-symbols/expand-all-rounded')['default']
'Mdi:announcement': typeof import('~icons/mdi/announcement')['default'] 'Mdi:announcement': typeof import('~icons/mdi/announcement')['default']
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default'] 'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
'Mdi:check': typeof import('~icons/mdi/check')['default'] 'Mdi:check': typeof import('~icons/mdi/check')['default']
@@ -70,7 +66,6 @@ declare module 'vue' {
'Mdi:hexagonMultiple': typeof import('~icons/mdi/hexagon-multiple')['default'] 'Mdi:hexagonMultiple': typeof import('~icons/mdi/hexagon-multiple')['default']
'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default'] 'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default']
'Mdi:magnify': typeof import('~icons/mdi/magnify')['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'] 'Mdi:satelliteVariant': typeof import('~icons/mdi/satellite-variant')['default']
MobileMenu: typeof import('./components/common/MobileMenu.vue')['default'] MobileMenu: typeof import('./components/common/MobileMenu.vue')['default']
MultiContainerActionToolbar: typeof import('./components/LogViewer/MultiContainerActionToolbar.vue')['default'] MultiContainerActionToolbar: typeof import('./components/LogViewer/MultiContainerActionToolbar.vue')['default']

View File

@@ -17,9 +17,9 @@
<li v-for="(value, name) in validValues" :key="name"> <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> <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)"> <template v-else-if="Array.isArray(value)">
<span class="font-bold" v-html="markSearch(JSON.stringify(value))"> </span> <span class="font-bold" v-html="JSON.stringify(value)"> </span>
</template> </template>
<span class="font-bold" v-html="markSearch(value)" v-else></span> <span class="font-bold" v-html="value" v-else></span>
</li> </li>
<li class="text-light" v-if="Object.keys(validValues).length === 0">all values are hidden</li> <li class="text-light" v-if="Object.keys(validValues).length === 0">all values are hidden</li>
</ul> </ul>
@@ -34,8 +34,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { type ComplexLogEntry } from "@/models/LogEntry"; import { type ComplexLogEntry } from "@/models/LogEntry";
const { markSearch } = useSearchFilter();
const { logEntry, showContainerName = false } = defineProps<{ const { logEntry, showContainerName = false } = defineProps<{
logEntry: ComplexLogEntry; logEntry: ComplexLogEntry;
showContainerName?: boolean; showContainerName?: boolean;

View File

@@ -50,7 +50,7 @@ describe("<ContainerEventSource />", () => {
}, },
) { ) {
settings.value.hourStyle = hourStyle; settings.value.hourStyle = hourStyle;
search.searchFilter.value = searchFilter; search.searchQueryFilter.value = searchFilter;
if (searchFilter) { if (searchFilter) {
search.showSearch.value = true; search.showSearch.value = true;
} }
@@ -146,24 +146,11 @@ describe("<ContainerEventSource />", () => {
expect(wrapper.find("ul.events").html()).toMatchSnapshot(); expect(wrapper.find("ul.events").html()).toMatchSnapshot();
}); });
test("should render messages with html entities", async () => {
const wrapper = createLogEventSource();
sources[sourceUrl].emitOpen();
sources[sourceUrl].emitMessage({
data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`,
});
vi.runAllTimers();
await nextTick();
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
});
test("should render dates with 12 hour style", async () => { test("should render dates with 12 hour style", async () => {
const wrapper = createLogEventSource({ hourStyle: "12" }); const wrapper = createLogEventSource({ hourStyle: "12" });
sources[sourceUrl].emitOpen(); sources[sourceUrl].emitOpen();
sources[sourceUrl].emitMessage({ sources[sourceUrl].emitMessage({
data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`, data: `{"ts":1560336942459, "m":"foo bar", "id":1}`,
}); });
vi.runAllTimers(); vi.runAllTimers();
@@ -175,25 +162,9 @@ describe("<ContainerEventSource />", () => {
test("should render dates with 24 hour style", async () => { test("should render dates with 24 hour style", async () => {
const wrapper = createLogEventSource({ hourStyle: "24" }); const wrapper = createLogEventSource({ hourStyle: "24" });
sources[sourceUrl].emitOpen(); sources[sourceUrl].emitOpen();
sources[sourceUrl].emitMessage({
data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`,
});
vi.runAllTimers();
await nextTick();
expect(wrapper.find("ul.events").html()).toMatchSnapshot();
});
test("should render messages with filter", async () => {
const wrapper = createLogEventSource({ searchFilter: "test" });
sources[sourceUrl].emitOpen();
sources[sourceUrl].emitMessage({ sources[sourceUrl].emitMessage({
data: `{"ts":1560336942459, "m":"foo bar", "id":1}`, data: `{"ts":1560336942459, "m":"foo bar", "id":1}`,
}); });
sources[sourceUrl].emitMessage({
data: `{"ts":1560336942459, "m":"test bar", "id":2}`,
});
vi.runAllTimers(); vi.runAllTimers();
await nextTick(); await nextTick();

View File

@@ -1,5 +1,5 @@
<template> <template>
<InfiniteLoader :onLoadMore="fetchMore" :enabled="!loadingMore && messages.length > 50" /> <InfiniteLoader :onLoadMore="fetchMore" :enabled="!loadingMore && messages.length > 10" />
<slot :messages="messages"></slot> <slot :messages="messages"></slot>
</template> </template>

View File

@@ -10,7 +10,6 @@
:key="item.id" :key="item.id"
:data-key="item.id" :data-key="item.id"
:data-time="item.date.getTime()" :data-time="item.date.getTime()"
:class="{ 'border border-secondary': toRaw(item) === toRaw(lastSelectedItem) }"
class="group/entry" class="group/entry"
> >
<component :is="item.getComponent()" :log-entry="item" :show-container-name="showContainerName" /> <component :is="item.getComponent()" :log-entry="item" :show-container-name="showContainerName" />
@@ -25,7 +24,6 @@ const { loading, progress, currentDate } = useScrollContext();
const { messages } = defineProps<{ const { messages } = defineProps<{
messages: LogEntry<string | JSONObject>[]; messages: LogEntry<string | JSONObject>[];
lastSelectedItem: LogEntry<string | JSONObject> | undefined;
showContainerName: boolean; showContainerName: boolean;
}>(); }>();

View File

@@ -12,26 +12,13 @@
<carbon:copy-file /> <carbon:copy-file />
</span> </span>
</div> </div>
<div
class="flex min-w-[0.98rem] items-start justify-end align-bottom hover:cursor-pointer"
:title="t('log_actions.jump_to_context')"
v-if="isSearching()"
>
<a
class="rounded bg-slate-800/60 px-1.5 py-1 text-primary hover:bg-slate-700"
@click.prevent="handleJumpLineSelected($event, logEntry)"
:href="`#${logEntry.id}`"
>
<carbon:search-locate />
</a>
</div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { LogEntry, JSONObject } from "@/models/LogEntry"; import { LogEntry, JSONObject } from "@/models/LogEntry";
const { message, logEntry } = defineProps<{ const { message } = defineProps<{
message: () => string; message: () => string;
logEntry: LogEntry<string | JSONObject>; logEntry: LogEntry<string | JSONObject>;
}>(); }>();
@@ -40,9 +27,6 @@ const { showToast } = useToast();
const { copy, isSupported, copied } = useClipboard(); const { copy, isSupported, copied } = useClipboard();
const { t } = useI18n(); const { t } = useI18n();
const { isSearching } = useSearchFilter();
const { handleJumpLineSelected } = useLogSearchContext();
async function copyLogMessageToClipBoard() { async function copyLogMessageToClipBoard() {
await copy(message()); await copy(message());

View File

@@ -2,11 +2,10 @@
<SideDrawer ref="drawer"> <SideDrawer ref="drawer">
<LogDetails :entry="entry" v-if="entry && entry instanceof ComplexLogEntry" /> <LogDetails :entry="entry" v-if="entry && entry instanceof ComplexLogEntry" />
</SideDrawer> </SideDrawer>
<LogList :messages="filtered" :last-selected-item="lastSelectedItem" :show-container-name="showContainerName" /> <LogList :messages="visibleMessages" :show-container-name="showContainerName" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useRouteHash } from "@vueuse/router";
import SideDrawer from "@/components/common/SideDrawer.vue"; import SideDrawer from "@/components/common/SideDrawer.vue";
import { ComplexLogEntry, type JSONObject, LogEntry } from "@/models/LogEntry"; import { ComplexLogEntry, type JSONObject, LogEntry } from "@/models/LogEntry";
@@ -19,27 +18,35 @@ const props = defineProps<{
const { messages, visibleKeys } = toRefs(props); const { messages, visibleKeys } = toRefs(props);
const { filteredPayload } = useVisibleFilter(visibleKeys); const { filteredPayload } = useVisibleFilter(visibleKeys);
const { filteredMessages } = useSearchFilter(); const { debouncedSearchFilter } = useSearchFilter();
const { streamConfig } = useLoggingContext();
const drawer = ref<InstanceType<typeof SideDrawer>>() as Ref<InstanceType<typeof SideDrawer>>; const drawer = ref<InstanceType<typeof SideDrawer>>() as Ref<InstanceType<typeof SideDrawer>>;
const { entry } = provideLogDetails(drawer); const { entry } = provideLogDetails(drawer);
const visible = filteredPayload(messages); const visibleMessages = filteredPayload(messages);
const filtered = filteredMessages(visible);
const { lastSelectedItem } = useLogSearchContext() as { const router = useRouter();
lastSelectedItem: Ref<LogEntry<string | JSONObject> | undefined>;
}; watchEffect(() => {
const routeHash = useRouteHash(); const query = {} as Record<string, string>;
watch( if (debouncedSearchFilter.value !== "") {
routeHash, query.search = debouncedSearchFilter.value;
(hash) => { }
if (hash) {
document.querySelector(`[data-key="${hash.substring(1)}"]`)?.scrollIntoView({ block: "center" }); if (!streamConfig.value.stderr) {
} query.stderr = streamConfig.value.stderr.toString();
}, }
{ immediate: true, flush: "post" },
); if (!streamConfig.value.stdout) {
query.stdout = streamConfig.value.stdout.toString();
}
router.push({
query,
replace: true,
});
});
</script> </script>
<style scoped lang="postcss"></style> <style scoped lang="postcss"></style>

View File

@@ -32,8 +32,7 @@ const { showContainerName = false } = defineProps<{
showContainerName?: boolean; showContainerName?: boolean;
}>(); }>();
const { markSearch } = useSearchFilter(); const colorize = (value: string) => ansiConvertor.toHtml(value);
const colorize = (value: string) => markSearch(ansiConvertor.toHtml(value));
const urlPattern = /(https?:\/\/[^\s]+)/g; const urlPattern = /(https?:\/\/[^\s]+)/g;
const linkify = (text: string) => const linkify = (text: string) =>
text.replace(urlPattern, (url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`); text.replace(urlPattern, (url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`);

View File

@@ -10,10 +10,9 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div> <div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
</div> </div>
<div data-v-e625cddd="" data-v-a49e52d4="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div> <div data-v-e625cddd="" data-v-a49e52d4="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
<div data-v-a49e52d4="" class="log-wrapper whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap">&lt;test&gt;foo bar&lt;/test&gt;</div> <div data-v-a49e52d4="" class="log-wrapper whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
<div data-v-a49e52d4="" class="flex gap-2 duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100"> <div data-v-a49e52d4="" class="flex gap-2 duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100">
<!--v-if--> <!--v-if-->
<!--v-if-->
</div> </div>
</div> </div>
</li> </li>
@@ -30,10 +29,9 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42</time></div> <div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42</time></div>
</div> </div>
<div data-v-e625cddd="" data-v-a49e52d4="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div> <div data-v-e625cddd="" data-v-a49e52d4="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
<div data-v-a49e52d4="" class="log-wrapper whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap">&lt;test&gt;foo bar&lt;/test&gt;</div> <div data-v-a49e52d4="" class="log-wrapper whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
<div data-v-a49e52d4="" class="flex gap-2 duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100"> <div data-v-a49e52d4="" class="flex gap-2 duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100">
<!--v-if--> <!--v-if-->
<!--v-if-->
</div> </div>
</div> </div>
</li> </li>
@@ -53,49 +51,6 @@ exports[`<ContainerEventSource /> > render html correctly > should render messag
<div data-v-a49e52d4="" class="log-wrapper whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap">This is a message.</div> <div data-v-a49e52d4="" class="log-wrapper whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap">This is a message.</div>
<div data-v-a49e52d4="" class="flex gap-2 duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100"> <div data-v-a49e52d4="" class="flex gap-2 duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100">
<!--v-if--> <!--v-if-->
<!--v-if-->
</div>
</div>
</li>
</ul>"
`;
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">
<!--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">
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
</div>
<div data-v-e625cddd="" data-v-a49e52d4="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
<div data-v-a49e52d4="" class="log-wrapper whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap"><mark>test</mark> bar</div>
<div data-v-a49e52d4="" class="flex gap-2 duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100">
<!--v-if-->
<div class="flex min-w-[0.98rem] items-start justify-end align-bottom hover:cursor-pointer" title="log_actions.jump_to_context"><a class="rounded bg-slate-800/60 px-1.5 py-1 text-primary hover:bg-slate-700" href="#2"><svg viewBox="0 0 32 32" width="1.2em" height="1.2em">
<path fill="currentColor" d="m30 28.586l-4.688-4.688a8.028 8.028 0 1 0-1.415 1.414L28.586 30zM19 25a6 6 0 1 1 6-6a6.007 6.007 0 0 1-6 6M2 12h8v2H2zM2 2h16v2H2zm0 5h16v2H2z"></path>
</svg></a></div>
</div>
</div>
</li>
</ul>"
`;
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">
<!--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">
<div class="inline-flex gap-2 whitespace-nowrap text-blue"><time datetime="2019-06-12T10:55:42.459Z" class="mobile-hidden">06/12/2019</time><time datetime="2019-06-12T10:55:42.459Z">10:55:42 AM</time></div>
</div>
<div data-v-e625cddd="" data-v-a49e52d4="" class="mt-1.5 size-2.5 flex-none rounded-lg flex"></div>
<div data-v-a49e52d4="" class="log-wrapper whitespace-pre-wrap [word-break:break-word] group-[.disable-wrap]:whitespace-nowrap">&lt;test&gt;foo bar&lt;/test&gt;</div>
<div data-v-a49e52d4="" class="flex gap-2 duration-250 absolute -right-1 opacity-0 transition-opacity delay-150 group-hover/entry:opacity-100">
<!--v-if-->
<!--v-if-->
</div> </div>
</div> </div>
</li> </li>

View File

@@ -7,14 +7,17 @@
ref="container" ref="container"
:style="style" :style="style"
> >
<div class="input input-primary flex h-auto items-center !shadow-lg"> <div
class="input input-primary flex h-auto items-center !shadow-lg"
:class="!isValidQuery ? 'input-warning' : ''"
>
<mdi:magnify /> <mdi:magnify />
<input <input
class="input input-ghost w-72 flex-1" class="input input-ghost w-72 flex-1"
type="text" type="text"
placeholder="Find / RegEx" placeholder="Find / RegEx"
ref="input" ref="input"
v-model="searchFilter" v-model="searchQueryFilter"
@keyup.esc="resetSearch()" @keyup.esc="resetSearch()"
/> />
<a class="btn btn-circle btn-xs" @click="resetSearch()"> <mdi:close /></a> <a class="btn btn-circle btn-xs" @click="resetSearch()"> <mdi:close /></a>
@@ -26,7 +29,7 @@
<script lang="ts" setup> <script lang="ts" setup>
const input = ref<HTMLInputElement>(); const input = ref<HTMLInputElement>();
const container = ref<HTMLDivElement>(); const container = ref<HTMLDivElement>();
const { searchFilter, showSearch, resetSearch } = useSearchFilter(); const { searchQueryFilter, showSearch, resetSearch, isValidQuery } = useSearchFilter();
const { style } = useDraggable(container); const { style } = useDraggable(container);

View File

@@ -1,5 +1,5 @@
import { ShallowRef, type Ref } from "vue"; import { ShallowRef, type Ref } from "vue";
import { encodeXML } from "entities";
import debounce from "lodash.debounce"; import debounce from "lodash.debounce";
import { import {
type LogEvent, type LogEvent,
@@ -12,78 +12,39 @@ import {
import { Service, Stack } from "@/models/Stack"; import { Service, Stack } from "@/models/Stack";
import { Container, GroupedContainers } from "@/models/Container"; import { Container, GroupedContainers } from "@/models/Container";
const { isSearching, debouncedSearchFilter } = useSearchFilter();
function parseMessage(data: string): LogEntry<string | JSONObject> { function parseMessage(data: string): LogEntry<string | JSONObject> {
const e = JSON.parse(data, (key, value) => { const e = JSON.parse(data) as LogEvent;
if (typeof value === "string") {
return encodeXML(value);
}
return value;
}) as LogEvent;
return asLogEntry(e); return asLogEntry(e);
} }
export function useContainerStream(container: Ref<Container>): LogStreamSource { export function useContainerStream(container: Ref<Container>): LogStreamSource {
const { streamConfig } = useLoggingContext(); const url = computed(() =>
withBase(`/api/hosts/${container.value.host}/containers/${container.value.id}/logs/stream`),
);
const url = computed(() => { const loadMoreUrl = computed(() =>
const params = Object.entries(toValue(streamConfig)) withBase(`/api/hosts/${container.value.host}/containers/${container.value.id}/logs`),
.filter(([, value]) => value) );
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {});
return withBase(
`/api/hosts/${container.value.host}/containers/${container.value.id}/logs/stream?${new URLSearchParams(params).toString()}`,
);
});
const loadMoreUrl = computed(() => {
const params = Object.entries(toValue(streamConfig))
.filter(([, value]) => value)
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {});
return withBase(
`/api/hosts/${container.value.host}/containers/${container.value.id}/logs?${new URLSearchParams(params).toString()}`,
);
});
return useLogStream(url, loadMoreUrl); return useLogStream(url, loadMoreUrl);
} }
export function useStackStream(stack: Ref<Stack>): LogStreamSource { export function useStackStream(stack: Ref<Stack>): LogStreamSource {
const { streamConfig } = useLoggingContext(); const url = computed(() => withBase(`/api/stacks/${stack.value.name}/logs/stream`));
const url = computed(() => {
const params = Object.entries(toValue(streamConfig))
.filter(([, value]) => value)
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {});
return withBase(`/api/stacks/${stack.value.name}/logs/stream?${new URLSearchParams(params).toString()}`);
});
return useLogStream(url); return useLogStream(url);
} }
export function useGroupedStream(group: Ref<GroupedContainers>): LogStreamSource { export function useGroupedStream(group: Ref<GroupedContainers>): LogStreamSource {
const { streamConfig } = useLoggingContext(); const url = computed(() => withBase(`/api/groups/${group.value.name}/logs/stream`));
const url = computed(() => {
const params = Object.entries(toValue(streamConfig))
.filter(([, value]) => value)
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {});
return withBase(`/api/groups/${group.value.name}/logs/stream?${new URLSearchParams(params).toString()}`);
});
return useLogStream(url); return useLogStream(url);
} }
export function useMergedStream(containers: Ref<Container[]>): LogStreamSource { export function useMergedStream(containers: Ref<Container[]>): LogStreamSource {
const { streamConfig } = useLoggingContext();
const url = computed(() => { const url = computed(() => {
const params = [ const ids = containers.value.map((c) => ["id", c.id]).join(",");
...Object.entries(toValue(streamConfig)).map(([key, value]) => [key, value ? "1" : "0"]), return withBase(`/api/hosts/${containers.value[0].host}/logs/mergedStream/${ids}`);
...containers.value.map((c) => ["id", c.id]),
];
return withBase(
`/api/hosts/${containers.value[0].host}/logs/mergedStream?${new URLSearchParams(params).toString()}`,
);
}); });
return useLogStream(url); return useLogStream(url);
@@ -135,11 +96,16 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
buffer.value = []; buffer.value = [];
} }
} else { } else {
let empty = false;
if (messages.value.length == 0) { if (messages.value.length == 0) {
// sort the buffer the very first time because of multiple logs in parallel // sort the buffer the very first time because of multiple logs in parallel
buffer.value.sort((a, b) => a.date.getTime() - b.date.getTime()); buffer.value.sort((a, b) => a.date.getTime() - b.date.getTime());
empty = true;
} }
messages.value = [...messages.value, ...buffer.value]; messages.value = [...messages.value, ...buffer.value];
if (isSearching && messages.value.length < 90 && empty) {
loadOlderLogs();
}
buffer.value = []; buffer.value = [];
} }
} }
@@ -159,15 +125,26 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
buffer.value = []; buffer.value = [];
} }
function connect({ clear } = { clear: true }) { const { streamConfig } = useLoggingContext();
close();
if (clear) { const params = computed(() => {
clearMessages(); const params = Object.entries(toValue(streamConfig))
.filter(([, value]) => value)
.reduce((acc, [key]) => ({ ...acc, [key]: "1" }), {} as Record<string, string>);
if (isSearching.value) {
params["filter"] = debouncedSearchFilter.value;
} }
es = new EventSource(url.value); return params;
});
const urlWithParams = computed(() => withBase(`${url.value}?${new URLSearchParams(params.value).toString()}`));
function connect({ clear } = { clear: true }) {
close();
if (clear) clearMessages();
es = new EventSource(urlWithParams.value);
es.addEventListener("container-event", (e) => { es.addEventListener("container-event", (e) => {
const event = JSON.parse((e as MessageEvent).data) as { actorId: string; name: string }; const event = JSON.parse((e as MessageEvent).data) as { actorId: string; name: string };
const containerEvent = new ContainerEventLogEntry( const containerEvent = new ContainerEventLogEntry(
@@ -190,7 +167,7 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
es.onerror = () => clearMessages(); es.onerror = () => clearMessages();
} }
watch(url, () => connect(), { immediate: true }); watch(urlWithParams, () => connect(), { immediate: true });
let fetchingInProgress = false; let fetchingInProgress = false;
@@ -208,11 +185,9 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
fetchingInProgress = true; fetchingInProgress = true;
try { try {
const stopWatcher = watchOnce(url, () => abortController.abort("stream changed")); const stopWatcher = watchOnce(url, () => abortController.abort("stream changed"));
const moreParams = { ...params.value, from: from.toISOString(), to: to.toISOString(), fill: "1" };
const logs = await ( const logs = await (
await fetch( await fetch(`${loadMoreUrl.value}?${new URLSearchParams(moreParams).toString()}`, { signal })
`${loadMoreUrl.value}&${new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), fill: "1" }).toString()}`,
{ signal },
)
).text(); ).text();
stopWatcher(); stopWatcher();

View File

@@ -8,12 +8,15 @@ type LogContext = {
// export for testing // export for testing
export const loggingContextKey = Symbol("loggingContext") as InjectionKey<LogContext>; export const loggingContextKey = Symbol("loggingContext") as InjectionKey<LogContext>;
const searchParams = new URLSearchParams(window.location.search);
const stdout = searchParams.has("stdout") ? searchParams.get("stdout") === "true" : true;
const stderr = searchParams.has("stderr") ? searchParams.get("stderr") === "true" : true;
export const provideLoggingContext = (containers: Ref<Container[]>) => { export const provideLoggingContext = (containers: Ref<Container[]>) => {
provide( provide(
loggingContextKey, loggingContextKey,
reactive({ reactive({
streamConfig: { stdout: true, stderr: true }, streamConfig: { stdout, stderr },
containers, containers,
loadingMore: false, loadingMore: false,
}), }),

View File

@@ -1,16 +0,0 @@
import { JSONObject, LogEntry } from "@/models/LogEntry";
const lastSelectedItem = ref<LogEntry<string | JSONObject> | undefined>(undefined);
export const useLogSearchContext = () => {
const { resetSearch } = useSearchFilter();
function handleJumpLineSelected(e: Event, item: LogEntry<string | JSONObject>) {
lastSelectedItem.value = item;
resetSearch();
}
return {
lastSelectedItem,
handleJumpLineSelected,
};
};

View File

@@ -1,86 +1,34 @@
import { type Ref } from "vue"; const searchQueryFilter = ref<string>("");
import { type LogEntry, type JSONObject, SimpleLogEntry, ComplexLogEntry } from "@/models/LogEntry"; const debouncedSearchFilter = refDebounced(searchQueryFilter);
import { encodeXML } from "entities";
const searchFilter = ref<string>("");
const debouncedSearchFilter = useDebounce(searchFilter);
const showSearch = ref(false); const showSearch = ref(false);
function matchRecord(record: Record<string, any>, regex: RegExp): boolean { const searchParams = new URLSearchParams(window.location.search);
for (const key in record) { if (searchParams.get("search") !== null && searchParams.get("search") !== "") {
const value = record[key]; searchQueryFilter.value = searchParams.get("search") || "";
if (typeof value === "string" && regex.test(value)) { showSearch.value = true;
return true; }
} function resetSearch() {
if (isObject(value) && regex.test(JSON.stringify(value))) { searchQueryFilter.value = "";
return true; showSearch.value = false;
}
if (Array.isArray(value) && matchRecord(value, regex)) {
return true;
}
}
return false;
} }
const isSearching = computed(() => showSearch.value && debouncedSearchFilter.value !== "");
const isValidQuery = computed(() => {
try {
new RegExp(searchQueryFilter.value);
return true;
} catch (e) {
return false;
}
});
export function useSearchFilter() { export function useSearchFilter() {
const regex = $computed(() => {
const isSmartCase = debouncedSearchFilter.value === debouncedSearchFilter.value.toLowerCase();
return new RegExp(encodeXML(debouncedSearchFilter.value), isSmartCase ? "i" : "");
});
function filteredMessages(messages: Ref<LogEntry<string | JSONObject>[]>) {
return computed(() => {
if (debouncedSearchFilter.value && showSearch.value) {
try {
return messages.value.filter((d) => {
if (d instanceof SimpleLogEntry) {
return regex.test(d.message);
} else if (d instanceof ComplexLogEntry) {
return matchRecord(d.message, regex);
}
return false;
});
} catch (e) {
if (e instanceof SyntaxError) {
console.info(`Ignoring SyntaxError from search.`, e);
return messages.value;
}
throw e;
}
}
return messages.value;
});
}
function markSearch(log: { toString(): string }): string;
function markSearch(log: string[]): string[];
function markSearch(log: { toString(): string } | string[]) {
if (!debouncedSearchFilter.value) {
return log;
}
if (Array.isArray(log)) {
return log.map((d) => markSearch(d));
}
const globalRegex = new RegExp(regex.source, regex.flags + "g");
return log.toString().replaceAll(globalRegex, (match) => `<mark>${match}</mark>`);
}
function resetSearch() {
searchFilter.value = "";
showSearch.value = false;
}
function isSearching() {
return showSearch.value && searchFilter.value;
}
return { return {
filteredMessages, searchQueryFilter,
searchFilter, isValidQuery,
debouncedSearchFilter,
showSearch, showSearch,
markSearch,
resetSearch, resetSearch,
isSearching, isSearching,
}; };

View File

@@ -1,16 +1,24 @@
import { ComplexLogEntry, type JSONObject, type LogEntry } from "@/models/LogEntry"; import { ComplexLogEntry, type JSONObject, type LogEntry } from "@/models/LogEntry";
import type { Ref } from "vue";
export function useVisibleFilter(visibleKeys: Ref<Map<string[], boolean>>) { export function useVisibleFilter(visibleKeys: Ref<Map<string[], boolean>>) {
const { isSearching } = useSearchFilter();
function filteredPayload(messages: Ref<LogEntry<string | JSONObject>[]>) { function filteredPayload(messages: Ref<LogEntry<string | JSONObject>[]>) {
return computed(() => { return computed(() => {
return messages.value.map((d) => { return messages.value
if (d instanceof ComplexLogEntry) { .map((d) => {
return ComplexLogEntry.fromLogEvent(d, visibleKeys); if (d instanceof ComplexLogEntry) {
} else { return ComplexLogEntry.fromLogEvent(d, visibleKeys);
return d; } else {
} return d;
}); }
})
.filter((d) => {
if (isSearching.value && d instanceof ComplexLogEntry) {
return Object.values(d.message).some((v) => JSON.stringify(v).includes("<mark>"));
} else {
return true;
}
});
}); });
} }

View File

@@ -58,7 +58,7 @@ export class SimpleLogEntry extends LogEntry<string> {
} }
export class ComplexLogEntry extends LogEntry<JSONObject> { export class ComplexLogEntry extends LogEntry<JSONObject> {
private readonly filteredMessage: ComputedRef<JSONObject>; private readonly filteredMessage: ComputedRef<Record<string, any>>;
constructor( constructor(
message: JSONObject, message: JSONObject,
@@ -89,7 +89,7 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
return ComplexLogItem; return ComplexLogItem;
} }
public get message(): JSONObject { public get message(): Record<string, any> {
return unref(this.filteredMessage); return unref(this.filteredMessage);
} }

8
go.sum
View File

@@ -29,8 +29,6 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnN
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.1.2+incompatible h1:AhGzR1xaQIy53qCkxARaFluI00WPGtXn0AJuoQsVYTY=
github.com/docker/docker v27.1.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
@@ -223,12 +221,10 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw= google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117 h1:+rdxYoE3E5htTEWIe15GlN6IfvbURM//Jt0mmkmm6ZU=
google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157/go.mod h1:99sLkeliLXfdj2J75X3Ho+rrVCaJze0uwN7zDDkjPVU= google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117/go.mod h1:OimBR/bc1wPO9iV4NC2bpyjy3VnAwZh5EBPQdtaE5oo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240730163845-b1a4ccb954bf h1:liao9UHurZLtiEwBgT9LMOnKYsHze6eA6w1KQCMVN2Q= google.golang.org/genproto/googleapis/rpc v0.0.0-20240730163845-b1a4ccb954bf h1:liao9UHurZLtiEwBgT9LMOnKYsHze6eA6w1KQCMVN2Q=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240730163845-b1a4ccb954bf/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= google.golang.org/genproto/googleapis/rpc v0.0.0-20240730163845-b1a4ccb954bf/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
google.golang.org/grpc v1.66.0 h1:DibZuoBznOxbDQxRINckZcUvnCEvrW9pcWIE2yF9r1c= google.golang.org/grpc v1.66.0 h1:DibZuoBznOxbDQxRINckZcUvnCEvrW9pcWIE2yF9r1c=
google.golang.org/grpc v1.66.0/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= google.golang.org/grpc v1.66.0/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=

View File

@@ -5,8 +5,8 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/json"
"fmt" "fmt"
"github.com/goccy/go-json"
"io" "io"
"time" "time"

View File

@@ -5,8 +5,8 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/json"
"fmt" "fmt"
"github.com/goccy/go-json"
"time" "time"

View File

@@ -2,8 +2,8 @@ package analytics
import ( import (
"bytes" "bytes"
"encoding/json"
"fmt" "fmt"
"github.com/goccy/go-json"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"

51
internal/docker/escape.go Normal file
View File

@@ -0,0 +1,51 @@
package docker
import (
"html"
"github.com/rs/zerolog/log"
orderedmap "github.com/wk8/go-ordered-map/v2"
)
func escape(logEvent *LogEvent) {
switch value := logEvent.Message.(type) {
case string:
logEvent.Message = html.EscapeString(value)
case *orderedmap.OrderedMap[string, any]:
escapeAnyMap(value)
case *orderedmap.OrderedMap[string, string]:
escapeStringMap(value)
case map[string]interface{}:
panic("not implemented")
case map[string]string:
panic("not implemented")
default:
log.Debug().Type("type", value).Msg("unknown logEvent type")
}
}
func escapeAnyMap(orderedMap *orderedmap.OrderedMap[string, any]) {
for pair := orderedMap.Oldest(); pair != nil; pair = pair.Next() {
switch value := pair.Value.(type) {
case string:
orderedMap.Set(pair.Key, html.EscapeString(value))
case *orderedmap.OrderedMap[string, any]:
escapeAnyMap(value)
case *orderedmap.OrderedMap[string, string]:
escapeStringMap(value)
}
}
}
func escapeStringMap(orderedMap *orderedmap.OrderedMap[string, string]) {
for pair := orderedMap.Oldest(); pair != nil; pair = pair.Next() {
orderedMap.Set(pair.Key, html.EscapeString(pair.Value))
}
}

View File

@@ -95,6 +95,7 @@ func (g *EventGenerator) consumeReader() {
logEvent := createEvent(message, streamType) logEvent := createEvent(message, streamType)
logEvent.ContainerID = g.containerID logEvent.ContainerID = g.containerID
logEvent.Level = guessLogLevel(logEvent) logEvent.Level = guessLogLevel(logEvent)
escape(logEvent)
g.buffer <- logEvent g.buffer <- logEvent
} }
@@ -192,7 +193,6 @@ func createEvent(message string, streamType StdType) *LogEvent {
logEvent.Message = data logEvent.Message = data
} }
} }
} else if data, err := ParseLogFmt(message); err == nil { } else if data, err := ParseLogFmt(message); err == nil {
logEvent.Message = data logEvent.Message = data
} }

View File

@@ -64,14 +64,10 @@ func guessLogLevel(logEvent *LogEvent) string {
} }
case map[string]interface{}: case map[string]interface{}:
if level, ok := value["level"].(string); ok { panic("not implemented")
return strings.ToLower(level)
}
case map[string]string: case map[string]string:
if level, ok := value["level"]; ok { panic("not implemented")
return strings.ToLower(level)
}
default: default:
log.Debug().Type("type", value).Msg("unknown logEvent type") log.Debug().Type("type", value).Msg("unknown logEvent type")

View File

@@ -1,7 +1,7 @@
package docker package docker
import ( import (
"encoding/json" "github.com/goccy/go-json"
"testing" "testing"
orderedmap "github.com/wk8/go-ordered-map/v2" orderedmap "github.com/wk8/go-ordered-map/v2"
@@ -29,10 +29,6 @@ func TestGuessLogLevel(t *testing.T) {
{"[foo] [ ERROR] Something went wrong", "error"}, {"[foo] [ ERROR] Something went wrong", "error"},
{"123 ERROR Something went wrong", "error"}, {"123 ERROR Something went wrong", "error"},
{"123 Something went wrong", ""}, {"123 Something went wrong", ""},
{map[string]interface{}{"level": "info"}, "info"},
{map[string]interface{}{"level": "INFO"}, "info"},
{map[string]string{"level": "info"}, "info"},
{map[string]string{"level": "INFO"}, "info"},
{orderedmap.New[string, string]( {orderedmap.New[string, string](
orderedmap.WithInitialData( orderedmap.WithInitialData(
orderedmap.Pair[string, string]{Key: "key", Value: "value"}, orderedmap.Pair[string, string]{Key: "key", Value: "value"},

View File

@@ -1,7 +1,7 @@
package docker package docker
import ( import (
"encoding/json" "github.com/goccy/go-json"
"reflect" "reflect"
"testing" "testing"

View File

@@ -1,8 +1,8 @@
package profile package profile
import ( import (
"encoding/json"
"errors" "errors"
"github.com/goccy/go-json"
"io" "io"
"sync" "sync"

View File

@@ -2,7 +2,7 @@ package releases
import ( import (
"bytes" "bytes"
"encoding/json" "github.com/goccy/go-json"
"net/http" "net/http"
"strings" "strings"
"time" "time"

View File

@@ -0,0 +1,140 @@
package search
import (
"regexp"
"strings"
"github.com/amir20/dozzle/internal/docker"
"github.com/rs/zerolog/log"
orderedmap "github.com/wk8/go-ordered-map/v2"
)
func ParseRegex(search string) (*regexp.Regexp, error) {
flags := ""
if search == strings.ToLower(search) {
flags = "(?i)"
}
re, err := regexp.Compile(flags + search)
if err != nil {
log.Debug().Err(err).Str("search", search).Msg("failed to compile regex")
return nil, err
}
return re, nil
}
func Search(re *regexp.Regexp, logEvent *docker.LogEvent) bool {
switch value := logEvent.Message.(type) {
case string:
if re.MatchString(value) {
logEvent.Message = re.ReplaceAllString(value, "<mark>$0</mark>")
return true
}
case *orderedmap.OrderedMap[string, any]:
return searchMapAny(re, value)
case *orderedmap.OrderedMap[string, string]:
return searchMapString(re, value)
case map[string]interface{}:
panic("not implemented")
case map[string]string:
panic("not implemented")
default:
log.Debug().Type("type", value).Msg("unknown logEvent type")
}
return false
}
func searchMapAny(re *regexp.Regexp, orderedMap *orderedmap.OrderedMap[string, any]) bool {
found := false
for pair := orderedMap.Oldest(); pair != nil; pair = pair.Next() {
switch value := pair.Value.(type) {
case string:
if re.MatchString(value) {
found = true
orderedMap.Set(pair.Key, re.ReplaceAllString(value, "<mark>$0</mark>"))
}
case []any:
for i, v := range value {
switch v := v.(type) {
case string:
if re.MatchString(v) {
found = true
value[i] = re.ReplaceAllString(v, "<mark>$0</mark>")
}
}
}
case *orderedmap.OrderedMap[string, any]:
if searchMapAny(re, value) {
found = true
}
case *orderedmap.OrderedMap[string, string]:
if searchMapString(re, value) {
found = true
}
case map[string]interface{}:
if searchMap(re, value) {
found = true
}
default:
log.Debug().Type("type", value).Msg("unknown logEvent type inside searchMapAny")
}
}
return found
}
func searchMap(re *regexp.Regexp, data map[string]interface{}) bool {
found := false
for key, value := range data {
switch value := value.(type) {
case string:
if re.MatchString(value) {
data[key] = re.ReplaceAllString(value, "<mark>$0</mark>")
found = true
}
case []any:
for i, v := range value {
switch v := v.(type) {
case string:
if re.MatchString(v) {
found = true
value[i] = re.ReplaceAllString(v, "<mark>$0</mark>")
}
}
}
case map[string]interface{}:
if searchMap(re, value) {
found = true
}
default:
log.Debug().Type("type", value).Msg("unknown logEvent type inside searchMap")
}
}
return found
}
func searchMapString(re *regexp.Regexp, orderedMap *orderedmap.OrderedMap[string, string]) bool {
found := false
for pair := orderedMap.Oldest(); pair != nil; pair = pair.Next() {
if re.MatchString(pair.Value) {
orderedMap.Set(pair.Key, re.ReplaceAllString(pair.Value, "<mark>$0</mark>"))
found = true
}
}
return found
}

View File

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

View File

@@ -4,6 +4,7 @@ import (
"compress/gzip" "compress/gzip"
"context" "context"
"errors" "errors"
"regexp"
"strings" "strings"
"github.com/goccy/go-json" "github.com/goccy/go-json"
@@ -16,6 +17,7 @@ import (
"time" "time"
"github.com/amir20/dozzle/internal/docker" "github.com/amir20/dozzle/internal/docker"
"github.com/amir20/dozzle/internal/support/search"
"github.com/amir20/dozzle/internal/utils" "github.com/amir20/dozzle/internal/utils"
"github.com/docker/docker/pkg/stdcopy" "github.com/docker/docker/pkg/stdcopy"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
@@ -106,6 +108,15 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
buffer := utils.NewRingBuffer[*docker.LogEvent](500) buffer := utils.NewRingBuffer[*docker.LogEvent](500)
delta := to.Sub(from) delta := to.Sub(from)
var regex *regexp.Regexp
if r.URL.Query().Has("filter") {
regex, err = search.ParseRegex(r.URL.Query().Get("filter"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
for { for {
if buffer.Len() > 0 { if buffer.Len() > 0 {
break break
@@ -118,7 +129,13 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
} }
for event := range events { for event := range events {
buffer.Push(event) if regex != nil {
if search.Search(regex, event) {
buffer.Push(event)
}
} else {
buffer.Push(event)
}
} }
if !r.URL.Query().Has("fill") { // only auto fill if fill query parameter is set if !r.URL.Query().Has("fill") { // only auto fill if fill query parameter is set
@@ -249,17 +266,31 @@ func streamLogsForContainers(w http.ResponseWriter, r *http.Request, multiHostCl
newContainers := make(chan docker.Container) newContainers := make(chan docker.Container)
multiHostClient.SubscribeContainersStarted(r.Context(), newContainers, filter) multiHostClient.SubscribeContainersStarted(r.Context(), newContainers, filter)
var regex *regexp.Regexp
if r.URL.Query().Has("filter") {
var err error
regex, err = search.ParseRegex(r.URL.Query().Get("filter"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
loop: loop:
for { for {
select { select {
case event := <-logs: case logEvent := <-logs:
if buf, err := json.Marshal(event); err != nil { if regex != nil {
if !search.Search(regex, logEvent) {
continue
}
}
if buf, err := json.Marshal(logEvent); err != nil {
log.Error().Err(err).Msg("error encoding log event") log.Error().Err(err).Msg("error encoding log event")
} else { } else {
fmt.Fprintf(w, "data: %s\n", buf) fmt.Fprintf(w, "data: %s\n", buf)
} }
if event.Timestamp > 0 { if logEvent.Timestamp > 0 {
fmt.Fprintf(w, "id: %d\n", event.Timestamp) fmt.Fprintf(w, "id: %d\n", logEvent.Timestamp)
} }
fmt.Fprintf(w, "\n") fmt.Fprintf(w, "\n")
f.Flush() f.Flush()

View File

@@ -1,7 +1,7 @@
package web package web
import ( import (
"encoding/json" "github.com/goccy/go-json"
"net/http" "net/http"
"time" "time"