1
0
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:
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 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']>

View File

@@ -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']

View File

@@ -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">&lt;null&gt;</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;

View File

@@ -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();

View File

@@ -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>

View File

@@ -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;
}>();

View File

@@ -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());

View File

@@ -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>

View File

@@ -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>`);

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>
<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">
<!--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">&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">
<!--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">&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>
</li>

View File

@@ -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);

View File

@@ -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();

View File

@@ -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,
}),

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";
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;
}
if (isObject(value) && regex.test(JSON.stringify(value))) {
return true;
}
if (Array.isArray(value) && matchRecord(value, regex)) {
return true;
}
}
return false;
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.get("search") !== null && searchParams.get("search") !== "") {
searchQueryFilter.value = searchParams.get("search") || "";
showSearch.value = true;
}
function resetSearch() {
searchQueryFilter.value = "";
showSearch.value = false;
}
export function useSearchFilter() {
const regex = $computed(() => {
const isSmartCase = debouncedSearchFilter.value === debouncedSearchFilter.value.toLowerCase();
return new RegExp(encodeXML(debouncedSearchFilter.value), isSmartCase ? "i" : "");
});
const isSearching = computed(() => showSearch.value && debouncedSearchFilter.value !== "");
function filteredMessages(messages: Ref<LogEntry<string | JSONObject>[]>) {
return computed(() => {
if (debouncedSearchFilter.value && showSearch.value) {
const isValidQuery = computed(() => {
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;
});
new RegExp(searchQueryFilter.value);
return true;
} 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 false;
}
});
export function useSearchFilter() {
return {
filteredMessages,
searchFilter,
searchQueryFilter,
isValidQuery,
debouncedSearchFilter,
showSearch,
markSearch,
resetSearch,
isSearching,
};

View File

@@ -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;
}
});
});
}

View File

@@ -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
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/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=

View File

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

View File

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

View File

@@ -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
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.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
}

View File

@@ -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")

View File

@@ -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"},

View File

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

View File

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

View File

@@ -2,7 +2,7 @@ package releases
import (
"bytes"
"encoding/json"
"github.com/goccy/go-json"
"net/http"
"strings"
"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())
}
func Test_createRoutes_redirect(t *testing.T) {
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, "index.html", []byte("index page"), 0644), "WriteFile should have no error.")

View File

@@ -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()

View File

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