mirror of
https://github.com/amir20/dozzle.git
synced 2025-12-21 13:23:07 +01:00
feat!: moves search to backend for better experience. It does remove show search results in context. (#3245)
This commit is contained in:
2
assets/auto-imports.d.ts
vendored
2
assets/auto-imports.d.ts
vendored
@@ -614,7 +614,6 @@ declare module 'vue' {
|
||||
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']>
|
||||
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 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']>
|
||||
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
|
||||
|
||||
5
assets/components.d.ts
vendored
5
assets/components.d.ts
vendored
@@ -14,7 +14,6 @@ declare module 'vue' {
|
||||
'Carbon:macShift': typeof import('~icons/carbon/mac-shift')['default']
|
||||
'Carbon:play': typeof import('~icons/carbon/play')['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:starFilled': typeof import('~icons/carbon/star-filled')['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']
|
||||
DropdownMenu: typeof import('./components/common/DropdownMenu.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']
|
||||
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.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']
|
||||
LogStd: typeof import('./components/LogViewer/LogStd.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:arrowUp': typeof import('~icons/mdi/arrow-up')['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: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']
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
<li v-for="(value, name) in validValues" :key="name">
|
||||
<span class="text-light">{{ name }}=</span><span class="font-bold" v-if="value === null"><null></span>
|
||||
<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>
|
||||
<span class="font-bold" v-html="markSearch(value)" v-else></span>
|
||||
<span class="font-bold" v-html="value" v-else></span>
|
||||
</li>
|
||||
<li class="text-light" v-if="Object.keys(validValues).length === 0">all values are hidden</li>
|
||||
</ul>
|
||||
@@ -34,8 +34,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { type ComplexLogEntry } from "@/models/LogEntry";
|
||||
|
||||
const { markSearch } = useSearchFilter();
|
||||
|
||||
const { logEntry, showContainerName = false } = defineProps<{
|
||||
logEntry: ComplexLogEntry;
|
||||
showContainerName?: boolean;
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("<ContainerEventSource />", () => {
|
||||
},
|
||||
) {
|
||||
settings.value.hourStyle = hourStyle;
|
||||
search.searchFilter.value = searchFilter;
|
||||
search.searchQueryFilter.value = searchFilter;
|
||||
if (searchFilter) {
|
||||
search.showSearch.value = true;
|
||||
}
|
||||
@@ -146,24 +146,11 @@ describe("<ContainerEventSource />", () => {
|
||||
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 () => {
|
||||
const wrapper = createLogEventSource({ hourStyle: "12" });
|
||||
sources[sourceUrl].emitOpen();
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"<test>foo bar</test>", "id":1}`,
|
||||
data: `{"ts":1560336942459, "m":"foo bar", "id":1}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
@@ -175,25 +162,9 @@ describe("<ContainerEventSource />", () => {
|
||||
test("should render dates with 24 hour style", async () => {
|
||||
const wrapper = createLogEventSource({ hourStyle: "24" });
|
||||
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({
|
||||
data: `{"ts":1560336942459, "m":"foo bar", "id":1}`,
|
||||
});
|
||||
sources[sourceUrl].emitMessage({
|
||||
data: `{"ts":1560336942459, "m":"test bar", "id":2}`,
|
||||
});
|
||||
|
||||
vi.runAllTimers();
|
||||
await nextTick();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<InfiniteLoader :onLoadMore="fetchMore" :enabled="!loadingMore && messages.length > 50" />
|
||||
<InfiniteLoader :onLoadMore="fetchMore" :enabled="!loadingMore && messages.length > 10" />
|
||||
<slot :messages="messages"></slot>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
:key="item.id"
|
||||
:data-key="item.id"
|
||||
:data-time="item.date.getTime()"
|
||||
:class="{ 'border border-secondary': toRaw(item) === toRaw(lastSelectedItem) }"
|
||||
class="group/entry"
|
||||
>
|
||||
<component :is="item.getComponent()" :log-entry="item" :show-container-name="showContainerName" />
|
||||
@@ -25,7 +24,6 @@ const { loading, progress, currentDate } = useScrollContext();
|
||||
|
||||
const { messages } = defineProps<{
|
||||
messages: LogEntry<string | JSONObject>[];
|
||||
lastSelectedItem: LogEntry<string | JSONObject> | undefined;
|
||||
showContainerName: boolean;
|
||||
}>();
|
||||
|
||||
|
||||
@@ -12,26 +12,13 @@
|
||||
<carbon:copy-file />
|
||||
</span>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { LogEntry, JSONObject } from "@/models/LogEntry";
|
||||
|
||||
const { message, logEntry } = defineProps<{
|
||||
const { message } = defineProps<{
|
||||
message: () => string;
|
||||
logEntry: LogEntry<string | JSONObject>;
|
||||
}>();
|
||||
@@ -40,9 +27,6 @@ const { showToast } = useToast();
|
||||
const { copy, isSupported, copied } = useClipboard();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { isSearching } = useSearchFilter();
|
||||
const { handleJumpLineSelected } = useLogSearchContext();
|
||||
|
||||
async function copyLogMessageToClipBoard() {
|
||||
await copy(message());
|
||||
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
<SideDrawer ref="drawer">
|
||||
<LogDetails :entry="entry" v-if="entry && entry instanceof ComplexLogEntry" />
|
||||
</SideDrawer>
|
||||
<LogList :messages="filtered" :last-selected-item="lastSelectedItem" :show-container-name="showContainerName" />
|
||||
<LogList :messages="visibleMessages" :show-container-name="showContainerName" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRouteHash } from "@vueuse/router";
|
||||
import SideDrawer from "@/components/common/SideDrawer.vue";
|
||||
import { ComplexLogEntry, type JSONObject, LogEntry } from "@/models/LogEntry";
|
||||
|
||||
@@ -19,27 +18,35 @@ const props = defineProps<{
|
||||
const { messages, visibleKeys } = toRefs(props);
|
||||
|
||||
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 { entry } = provideLogDetails(drawer);
|
||||
|
||||
const visible = filteredPayload(messages);
|
||||
const filtered = filteredMessages(visible);
|
||||
const visibleMessages = filteredPayload(messages);
|
||||
|
||||
const { lastSelectedItem } = useLogSearchContext() as {
|
||||
lastSelectedItem: Ref<LogEntry<string | JSONObject> | undefined>;
|
||||
};
|
||||
const routeHash = useRouteHash();
|
||||
watch(
|
||||
routeHash,
|
||||
(hash) => {
|
||||
if (hash) {
|
||||
document.querySelector(`[data-key="${hash.substring(1)}"]`)?.scrollIntoView({ block: "center" });
|
||||
const router = useRouter();
|
||||
|
||||
watchEffect(() => {
|
||||
const query = {} as Record<string, string>;
|
||||
if (debouncedSearchFilter.value !== "") {
|
||||
query.search = debouncedSearchFilter.value;
|
||||
}
|
||||
},
|
||||
{ immediate: true, flush: "post" },
|
||||
);
|
||||
|
||||
if (!streamConfig.value.stderr) {
|
||||
query.stderr = streamConfig.value.stderr.toString();
|
||||
}
|
||||
|
||||
if (!streamConfig.value.stdout) {
|
||||
query.stdout = streamConfig.value.stdout.toString();
|
||||
}
|
||||
|
||||
router.push({
|
||||
query,
|
||||
replace: true,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="postcss"></style>
|
||||
|
||||
@@ -32,8 +32,7 @@ const { showContainerName = false } = defineProps<{
|
||||
showContainerName?: boolean;
|
||||
}>();
|
||||
|
||||
const { markSearch } = useSearchFilter();
|
||||
const colorize = (value: string) => markSearch(ansiConvertor.toHtml(value));
|
||||
const colorize = (value: string) => ansiConvertor.toHtml(value);
|
||||
const urlPattern = /(https?:\/\/[^\s]+)/g;
|
||||
const linkify = (text: string) =>
|
||||
text.replace(urlPattern, (url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`);
|
||||
|
||||
@@ -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>
|
||||
<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"><test>foo bar</test></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">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<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"><test>foo bar</test></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">
|
||||
<!--v-if-->
|
||||
<!--v-if-->
|
||||
</div>
|
||||
</div>
|
||||
</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="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>
|
||||
</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"><test>foo bar</test></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>
|
||||
</li>
|
||||
|
||||
@@ -7,14 +7,17 @@
|
||||
ref="container"
|
||||
: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 />
|
||||
<input
|
||||
class="input input-ghost w-72 flex-1"
|
||||
type="text"
|
||||
placeholder="Find / RegEx"
|
||||
ref="input"
|
||||
v-model="searchFilter"
|
||||
v-model="searchQueryFilter"
|
||||
@keyup.esc="resetSearch()"
|
||||
/>
|
||||
<a class="btn btn-circle btn-xs" @click="resetSearch()"> <mdi:close /></a>
|
||||
@@ -26,7 +29,7 @@
|
||||
<script lang="ts" setup>
|
||||
const input = ref<HTMLInputElement>();
|
||||
const container = ref<HTMLDivElement>();
|
||||
const { searchFilter, showSearch, resetSearch } = useSearchFilter();
|
||||
const { searchQueryFilter, showSearch, resetSearch, isValidQuery } = useSearchFilter();
|
||||
|
||||
const { style } = useDraggable(container);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ShallowRef, type Ref } from "vue";
|
||||
import { encodeXML } from "entities";
|
||||
|
||||
import debounce from "lodash.debounce";
|
||||
import {
|
||||
type LogEvent,
|
||||
@@ -12,78 +12,39 @@ import {
|
||||
import { Service, Stack } from "@/models/Stack";
|
||||
import { Container, GroupedContainers } from "@/models/Container";
|
||||
|
||||
const { isSearching, debouncedSearchFilter } = useSearchFilter();
|
||||
|
||||
function parseMessage(data: string): LogEntry<string | JSONObject> {
|
||||
const e = JSON.parse(data, (key, value) => {
|
||||
if (typeof value === "string") {
|
||||
return encodeXML(value);
|
||||
}
|
||||
return value;
|
||||
}) as LogEvent;
|
||||
const e = JSON.parse(data) as LogEvent;
|
||||
return asLogEntry(e);
|
||||
}
|
||||
|
||||
export function useContainerStream(container: Ref<Container>): LogStreamSource {
|
||||
const { streamConfig } = useLoggingContext();
|
||||
|
||||
const url = 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/stream?${new URLSearchParams(params).toString()}`,
|
||||
const url = computed(() =>
|
||||
withBase(`/api/hosts/${container.value.host}/containers/${container.value.id}/logs/stream`),
|
||||
);
|
||||
});
|
||||
|
||||
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()}`,
|
||||
const loadMoreUrl = computed(() =>
|
||||
withBase(`/api/hosts/${container.value.host}/containers/${container.value.id}/logs`),
|
||||
);
|
||||
});
|
||||
|
||||
return useLogStream(url, loadMoreUrl);
|
||||
}
|
||||
|
||||
export function useStackStream(stack: Ref<Stack>): LogStreamSource {
|
||||
const { streamConfig } = useLoggingContext();
|
||||
|
||||
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()}`);
|
||||
});
|
||||
|
||||
const url = computed(() => withBase(`/api/stacks/${stack.value.name}/logs/stream`));
|
||||
return useLogStream(url);
|
||||
}
|
||||
|
||||
export function useGroupedStream(group: Ref<GroupedContainers>): LogStreamSource {
|
||||
const { streamConfig } = useLoggingContext();
|
||||
|
||||
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()}`);
|
||||
});
|
||||
|
||||
const url = computed(() => withBase(`/api/groups/${group.value.name}/logs/stream`));
|
||||
return useLogStream(url);
|
||||
}
|
||||
|
||||
export function useMergedStream(containers: Ref<Container[]>): LogStreamSource {
|
||||
const { streamConfig } = useLoggingContext();
|
||||
|
||||
const url = computed(() => {
|
||||
const params = [
|
||||
...Object.entries(toValue(streamConfig)).map(([key, value]) => [key, value ? "1" : "0"]),
|
||||
...containers.value.map((c) => ["id", c.id]),
|
||||
];
|
||||
|
||||
return withBase(
|
||||
`/api/hosts/${containers.value[0].host}/logs/mergedStream?${new URLSearchParams(params).toString()}`,
|
||||
);
|
||||
const ids = containers.value.map((c) => ["id", c.id]).join(",");
|
||||
return withBase(`/api/hosts/${containers.value[0].host}/logs/mergedStream/${ids}`);
|
||||
});
|
||||
|
||||
return useLogStream(url);
|
||||
@@ -135,11 +96,16 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
|
||||
buffer.value = [];
|
||||
}
|
||||
} else {
|
||||
let empty = false;
|
||||
if (messages.value.length == 0) {
|
||||
// sort the buffer the very first time because of multiple logs in parallel
|
||||
buffer.value.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
empty = true;
|
||||
}
|
||||
messages.value = [...messages.value, ...buffer.value];
|
||||
if (isSearching && messages.value.length < 90 && empty) {
|
||||
loadOlderLogs();
|
||||
}
|
||||
buffer.value = [];
|
||||
}
|
||||
}
|
||||
@@ -159,15 +125,26 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
|
||||
buffer.value = [];
|
||||
}
|
||||
|
||||
function connect({ clear } = { clear: true }) {
|
||||
close();
|
||||
const { streamConfig } = useLoggingContext();
|
||||
|
||||
if (clear) {
|
||||
clearMessages();
|
||||
const params = computed(() => {
|
||||
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) => {
|
||||
const event = JSON.parse((e as MessageEvent).data) as { actorId: string; name: string };
|
||||
const containerEvent = new ContainerEventLogEntry(
|
||||
@@ -190,7 +167,7 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
|
||||
es.onerror = () => clearMessages();
|
||||
}
|
||||
|
||||
watch(url, () => connect(), { immediate: true });
|
||||
watch(urlWithParams, () => connect(), { immediate: true });
|
||||
|
||||
let fetchingInProgress = false;
|
||||
|
||||
@@ -208,11 +185,9 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
|
||||
fetchingInProgress = true;
|
||||
try {
|
||||
const stopWatcher = watchOnce(url, () => abortController.abort("stream changed"));
|
||||
const moreParams = { ...params.value, from: from.toISOString(), to: to.toISOString(), fill: "1" };
|
||||
const logs = await (
|
||||
await fetch(
|
||||
`${loadMoreUrl.value}&${new URLSearchParams({ from: from.toISOString(), to: to.toISOString(), fill: "1" }).toString()}`,
|
||||
{ signal },
|
||||
)
|
||||
await fetch(`${loadMoreUrl.value}?${new URLSearchParams(moreParams).toString()}`, { signal })
|
||||
).text();
|
||||
stopWatcher();
|
||||
|
||||
|
||||
@@ -8,12 +8,15 @@ type LogContext = {
|
||||
|
||||
// export for testing
|
||||
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[]>) => {
|
||||
provide(
|
||||
loggingContextKey,
|
||||
reactive({
|
||||
streamConfig: { stdout: true, stderr: true },
|
||||
streamConfig: { stdout, stderr },
|
||||
containers,
|
||||
loadingMore: false,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1,86 +1,34 @@
|
||||
import { type Ref } from "vue";
|
||||
import { type LogEntry, type JSONObject, SimpleLogEntry, ComplexLogEntry } from "@/models/LogEntry";
|
||||
import { encodeXML } from "entities";
|
||||
|
||||
const searchFilter = ref<string>("");
|
||||
const debouncedSearchFilter = useDebounce(searchFilter);
|
||||
const searchQueryFilter = ref<string>("");
|
||||
const debouncedSearchFilter = refDebounced(searchQueryFilter);
|
||||
const showSearch = ref(false);
|
||||
|
||||
function matchRecord(record: Record<string, any>, regex: RegExp): boolean {
|
||||
for (const key in record) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && regex.test(value)) {
|
||||
return true;
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
if (searchParams.get("search") !== null && searchParams.get("search") !== "") {
|
||||
searchQueryFilter.value = searchParams.get("search") || "";
|
||||
showSearch.value = true;
|
||||
}
|
||||
if (isObject(value) && regex.test(JSON.stringify(value))) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value) && matchRecord(value, regex)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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 = "";
|
||||
searchQueryFilter.value = "";
|
||||
showSearch.value = false;
|
||||
}
|
||||
|
||||
function isSearching() {
|
||||
return showSearch.value && searchFilter.value;
|
||||
}
|
||||
const isSearching = computed(() => showSearch.value && debouncedSearchFilter.value !== "");
|
||||
|
||||
const isValidQuery = computed(() => {
|
||||
try {
|
||||
new RegExp(searchQueryFilter.value);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
export function useSearchFilter() {
|
||||
return {
|
||||
filteredMessages,
|
||||
searchFilter,
|
||||
searchQueryFilter,
|
||||
isValidQuery,
|
||||
debouncedSearchFilter,
|
||||
showSearch,
|
||||
markSearch,
|
||||
resetSearch,
|
||||
isSearching,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { ComplexLogEntry, type JSONObject, type LogEntry } from "@/models/LogEntry";
|
||||
import type { Ref } from "vue";
|
||||
|
||||
export function useVisibleFilter(visibleKeys: Ref<Map<string[], boolean>>) {
|
||||
const { isSearching } = useSearchFilter();
|
||||
function filteredPayload(messages: Ref<LogEntry<string | JSONObject>[]>) {
|
||||
return computed(() => {
|
||||
return messages.value.map((d) => {
|
||||
return messages.value
|
||||
.map((d) => {
|
||||
if (d instanceof ComplexLogEntry) {
|
||||
return ComplexLogEntry.fromLogEvent(d, visibleKeys);
|
||||
} 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export class SimpleLogEntry extends LogEntry<string> {
|
||||
}
|
||||
|
||||
export class ComplexLogEntry extends LogEntry<JSONObject> {
|
||||
private readonly filteredMessage: ComputedRef<JSONObject>;
|
||||
private readonly filteredMessage: ComputedRef<Record<string, any>>;
|
||||
|
||||
constructor(
|
||||
message: JSONObject,
|
||||
@@ -89,7 +89,7 @@ export class ComplexLogEntry extends LogEntry<JSONObject> {
|
||||
return ComplexLogItem;
|
||||
}
|
||||
|
||||
public get message(): JSONObject {
|
||||
public get message(): Record<string, any> {
|
||||
return unref(this.filteredMessage);
|
||||
}
|
||||
|
||||
|
||||
8
go.sum
8
go.sum
@@ -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/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/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/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
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-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/googleapis/api v0.0.0-20240528184218-531527333157 h1:7whR9kGa5LUwFtpLm2ArCEejtnxlGeLbAyjFY8sGNFw=
|
||||
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 h1:+rdxYoE3E5htTEWIe15GlN6IfvbURM//Jt0mmkmm6ZU=
|
||||
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/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/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/goccy/go-json"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/goccy/go-json"
|
||||
|
||||
"time"
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ package analytics
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/goccy/go-json"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
|
||||
|
||||
51
internal/docker/escape.go
Normal file
51
internal/docker/escape.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,7 @@ func (g *EventGenerator) consumeReader() {
|
||||
logEvent := createEvent(message, streamType)
|
||||
logEvent.ContainerID = g.containerID
|
||||
logEvent.Level = guessLogLevel(logEvent)
|
||||
escape(logEvent)
|
||||
g.buffer <- logEvent
|
||||
}
|
||||
|
||||
@@ -192,7 +193,6 @@ func createEvent(message string, streamType StdType) *LogEvent {
|
||||
logEvent.Message = data
|
||||
}
|
||||
}
|
||||
|
||||
} else if data, err := ParseLogFmt(message); err == nil {
|
||||
logEvent.Message = data
|
||||
}
|
||||
|
||||
@@ -64,14 +64,10 @@ func guessLogLevel(logEvent *LogEvent) string {
|
||||
}
|
||||
|
||||
case map[string]interface{}:
|
||||
if level, ok := value["level"].(string); ok {
|
||||
return strings.ToLower(level)
|
||||
}
|
||||
panic("not implemented")
|
||||
|
||||
case map[string]string:
|
||||
if level, ok := value["level"]; ok {
|
||||
return strings.ToLower(level)
|
||||
}
|
||||
panic("not implemented")
|
||||
|
||||
default:
|
||||
log.Debug().Type("type", value).Msg("unknown logEvent type")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/goccy/go-json"
|
||||
"testing"
|
||||
|
||||
orderedmap "github.com/wk8/go-ordered-map/v2"
|
||||
@@ -29,10 +29,6 @@ func TestGuessLogLevel(t *testing.T) {
|
||||
{"[foo] [ ERROR] Something went wrong", "error"},
|
||||
{"123 ERROR Something went wrong", "error"},
|
||||
{"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.WithInitialData(
|
||||
orderedmap.Pair[string, string]{Key: "key", Value: "value"},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/goccy/go-json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/goccy/go-json"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package releases
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"github.com/goccy/go-json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
140
internal/support/search/search.go
Normal file
140
internal/support/search/search.go
Normal 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
|
||||
}
|
||||
@@ -27,7 +27,6 @@ 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.")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
@@ -16,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/amir20/dozzle/internal/docker"
|
||||
"github.com/amir20/dozzle/internal/support/search"
|
||||
"github.com/amir20/dozzle/internal/utils"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"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)
|
||||
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 {
|
||||
if buffer.Len() > 0 {
|
||||
break
|
||||
@@ -118,8 +129,14 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
for event := range events {
|
||||
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
|
||||
break
|
||||
@@ -249,17 +266,31 @@ func streamLogsForContainers(w http.ResponseWriter, r *http.Request, multiHostCl
|
||||
newContainers := make(chan docker.Container)
|
||||
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:
|
||||
for {
|
||||
select {
|
||||
case event := <-logs:
|
||||
if buf, err := json.Marshal(event); err != nil {
|
||||
case logEvent := <-logs:
|
||||
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")
|
||||
} else {
|
||||
fmt.Fprintf(w, "data: %s\n", buf)
|
||||
}
|
||||
if event.Timestamp > 0 {
|
||||
fmt.Fprintf(w, "id: %d\n", event.Timestamp)
|
||||
if logEvent.Timestamp > 0 {
|
||||
fmt.Fprintf(w, "id: %d\n", logEvent.Timestamp)
|
||||
}
|
||||
fmt.Fprintf(w, "\n")
|
||||
f.Flush()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/goccy/go-json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user