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

feat: adds the ability to show a specific log from the past with a permanent link (#3958)

This commit is contained in:
Amir Raminfar
2025-06-11 16:23:48 -07:00
committed by GitHub
parent a8dced5c2b
commit d98000b35a
38 changed files with 669 additions and 232 deletions

View File

@@ -69,6 +69,7 @@ declare global {
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const lightTheme: typeof import('./stores/settings')['lightTheme']
const loadBetween: typeof import('./composable/eventStreams')['loadBetween']
const locale: typeof import('./stores/settings')['locale']
const loggingContextKey: typeof import('./composable/logContext')['loggingContextKey']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
@@ -239,6 +240,7 @@ declare global {
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useGroupedStream: typeof import('./composable/eventStreams')['useGroupedStream']
const useHead: typeof import('@vueuse/head')['useHead']
const useHistoricalContainerLog: typeof import('./composable/historicalLogs')['useHistoricalContainerLog']
const useHostStream: typeof import('./composable/eventStreams')['useHostStream']
const useHosts: typeof import('./stores/hosts')['useHosts']
const useI18n: typeof import('vue-i18n')['useI18n']
@@ -461,6 +463,7 @@ declare module 'vue' {
readonly isReadonly: UnwrapRef<typeof import('vue')['isReadonly']>
readonly isRef: UnwrapRef<typeof import('vue')['isRef']>
readonly lightTheme: UnwrapRef<typeof import('./stores/settings')['lightTheme']>
readonly loadBetween: UnwrapRef<typeof import('./composable/eventStreams')['loadBetween']>
readonly locale: UnwrapRef<typeof import('./stores/settings')['locale']>
readonly loggingContextKey: UnwrapRef<typeof import('./composable/logContext')['loggingContextKey']>
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
@@ -631,6 +634,7 @@ declare module 'vue' {
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
readonly useGroupedStream: UnwrapRef<typeof import('./composable/eventStreams')['useGroupedStream']>
readonly useHead: UnwrapRef<typeof import('@vueuse/head')['useHead']>
readonly useHistoricalContainerLog: UnwrapRef<typeof import('./composable/historicalLogs')['useHistoricalContainerLog']>
readonly useHostStream: UnwrapRef<typeof import('./composable/eventStreams')['useHostStream']>
readonly useHosts: UnwrapRef<typeof import('./stores/hosts')['useHosts']>
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>

View File

@@ -11,7 +11,6 @@ declare module 'vue' {
Announcements: typeof import('./components/Announcements.vue')['default']
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default']
'Carbon:copyFile': typeof import('~icons/carbon/copy-file')['default']
'Carbon:information': typeof import('~icons/carbon/information')['default']
'Carbon:logoKubernetes': typeof import('~icons/carbon/logo-kubernetes')['default']
'Carbon:macShift': typeof import('~icons/carbon/mac-shift')['default']
@@ -43,25 +42,31 @@ declare module 'vue' {
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
GroupedLog: typeof import('./components/GroupedViewer/GroupedLog.vue')['default']
GroupMenu: typeof import('./components/GroupMenu.vue')['default']
HistoricalContainerLog: typeof import('./components/ContainerViewer/HistoricalContainerLog.vue')['default']
HostIcon: typeof import('./components/common/HostIcon.vue')['default']
HostList: typeof import('./components/HostList.vue')['default']
HostLog: typeof import('./components/HostViewer/HostLog.vue')['default']
HostMenu: typeof import('./components/HostMenu.vue')['default']
'Ic:sharpKeyboardReturn': typeof import('~icons/ic/sharp-keyboard-return')['default']
IndeterminateBar: typeof import('./components/common/IndeterminateBar.vue')['default']
'Ion:ellipsisVertical': typeof import('~icons/ion/ellipsis-vertical')['default']
KeyShortcut: typeof import('./components/common/KeyShortcut.vue')['default']
LabeledInput: typeof import('./components/common/LabeledInput.vue')['default']
Links: typeof import('./components/Links.vue')['default']
LoadMoreLogItem: typeof import('./components/LogViewer/LoadMoreLogItem.vue')['default']
LogActions: typeof import('./components/LogViewer/LogActions.vue')['default']
LogAnalytics: typeof import('./components/LogViewer/LogAnalytics.vue')['default']
LogDate: typeof import('./components/LogViewer/LogDate.vue')['default']
LogDetails: typeof import('./components/LogViewer/LogDetails.vue')['default']
LogItem: typeof import('./components/LogViewer/LogItem.vue')['default']
LogLevel: typeof import('./components/LogViewer/LogLevel.vue')['default']
LogList: typeof import('./components/LogViewer/LogList.vue')['default']
LogMessageActions: typeof import('./components/LogViewer/LogMessageActions.vue')['default']
LogStd: typeof import('./components/LogViewer/LogStd.vue')['default']
LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default']
'MaterialSymbols:codeBlocksRounded': typeof import('~icons/material-symbols/code-blocks-rounded')['default']
'MaterialSymbols:contentCopy': typeof import('~icons/material-symbols/content-copy')['default']
'MaterialSymbols:eyeTracking': typeof import('~icons/material-symbols/eye-tracking')['default']
'MaterialSymbols:link': typeof import('~icons/material-symbols/link')['default']
'MaterialSymbols:logout': typeof import('~icons/material-symbols/logout')['default']
'MaterialSymbols:terminal': typeof import('~icons/material-symbols/terminal')['default']
'MaterialSymbolsLight:collapseAll': typeof import('~icons/material-symbols-light/collapse-all')['default']
@@ -75,7 +80,6 @@ declare module 'vue' {
'Mdi:chevronRight': typeof import('~icons/mdi/chevron-right')['default']
'Mdi:close': typeof import('~icons/mdi/close')['default']
'Mdi:cog': typeof import('~icons/mdi/cog')['default']
'Mdi:contentCopy': typeof import('~icons/mdi/content-copy')['default']
'Mdi:docker': typeof import('~icons/mdi/docker')['default']
'Mdi:gauge': typeof import('~icons/mdi/gauge')['default']
'Mdi:github': typeof import('~icons/mdi/github')['default']
@@ -83,6 +87,7 @@ declare module 'vue' {
'Mdi:hexagonMultiple': typeof import('~icons/mdi/hexagon-multiple')['default']
'Mdi:key': typeof import('~icons/mdi/key')['default']
'Mdi:keyboardEsc': typeof import('~icons/mdi/keyboard-esc')['default']
'Mdi:lightningBolt': typeof import('~icons/mdi/lightning-bolt')['default']
'Mdi:magnify': typeof import('~icons/mdi/magnify')['default']
'Mdi:satelliteVariant': typeof import('~icons/mdi/satellite-variant')['default']
MobileMenu: typeof import('./components/common/MobileMenu.vue')['default']

View File

@@ -1,7 +1,7 @@
<template>
<div class="dropdown">
<button tabindex="0" role="button" class="btn btn-xs md:btn-sm"><slot /> <carbon:caret-down /></button>
<ul tabindex="0" class="dropdown-content menu rounded-box bg-base-100 shadow-sm">
<ul tabindex="0" class="dropdown-content menu rounded-box bg-base-100 border-base-content/20 border shadow-sm">
<li v-for="other in containers">
<router-link :to="{ name: '/container/[id]', params: { id: other.id } }" class="text-nowrap">
<div

View File

@@ -4,8 +4,12 @@
<carbon:circle-solid class="text-red w-2.5" v-if="streamConfig.stderr" />
<carbon:circle-solid class="text-blue w-2.5" v-if="streamConfig.stdout" />
</label>
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 z-50 w-52 p-1 shadow-sm" @click="hideMenu">
<li>
<ul
tabindex="0"
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
@click="hideMenu"
>
<li v-if="!historical">
<a @click.prevent="clear()">
<octicon:trash-24 /> {{ $t("toolbar.clear") }}
<KeyShortcut char="k" :modifiers="['shift', 'meta']" />
@@ -14,7 +18,7 @@
<li>
<a :href="downloadUrl" download> <octicon:download-24 /> {{ $t("toolbar.download") }} </a>
</li>
<li>
<li v-if="!historical">
<a @click.prevent="showSearch = true">
<mdi:magnify /> {{ $t("toolbar.search") }}
<KeyShortcut char="f" />
@@ -103,7 +107,7 @@
</li>
<!-- Container Actions (Enabled via config) -->
<template v-if="enableActions">
<template v-if="enableActions && !historical">
<li class="line"></li>
<li>
<button
@@ -135,7 +139,7 @@
</li>
</template>
<template v-if="enableShell">
<template v-if="enableShell && !historical">
<li class="line"></li>
<li>
<a @click.prevent="showDrawer(Terminal, { container, action: 'attach' }, 'lg')">
@@ -165,7 +169,7 @@ const { enableActions, enableShell } = config;
const { streamConfig, hasComplexLogs, levels } = useLoggingContext();
const showDrawer = useDrawer();
const { container } = defineProps<{ container: Container }>();
const { container, historical = false } = defineProps<{ container: Container; historical?: boolean }>();
const clear = defineEmit();
const { actionStates, start, stop, restart } = useContainerActions(toRef(() => container));

View File

@@ -18,7 +18,10 @@
<button tabindex="0" role="button" class="btn btn-xs md:btn-sm">
{{ container.name }} <carbon:caret-down />
</button>
<ul tabindex="0" class="dropdown-content menu rounded-box bg-base-100 shadow-sm">
<ul
tabindex="0"
class="dropdown-content menu rounded-box bg-base-100 border-base-content/20 border shadow-sm"
>
<li v-for="other in otherContainers">
<router-link :to="{ name: '/container/[id]', params: { id: other.id } }">
<div

View File

@@ -0,0 +1,63 @@
<template>
<ScrollableView :scrollable="scrollable" v-if="container">
<template #header v-if="showTitle">
<div class="@container mx-2 flex items-center gap-2 md:ml-4">
<ContainerTitle :container="container" />
<router-link
:to="{ name: '/container/[id]', params: { id: container.id } }"
class="btn btn-secondary btn-sm"
v-if="container.state === 'running'"
>
<mdi:lightning-bolt />
Live Logs
</router-link>
<ContainerActionsToolbar class="max-md:hidden" :container="container" historical />
<a class="btn btn-circle btn-xs" @click="close()" v-if="closable">
<mdi:close />
</a>
</div>
</template>
<template #default>
<ViewerWithSource
ref="viewer"
:stream-source="useHistoricalContainerLog"
:entity="historicalContainer"
:visible-keys="visibleKeys"
/>
</template>
</ScrollableView>
</template>
<script lang="ts" setup>
import ViewerWithSource from "@/components/LogViewer/ViewerWithSource.vue";
import { HistoricalContainer } from "@/models/Container";
import { ComponentExposed } from "vue-component-type-helpers";
const {
id,
showTitle = false,
scrollable = false,
closable = false,
date,
} = defineProps<{
id: string;
showTitle?: boolean;
scrollable?: boolean;
closable?: boolean;
date: Date;
}>();
const close = defineEmit();
const store = useContainerStore();
const container = store.currentContainer(toRef(() => id));
const historicalContainer = toRef(() => new HistoricalContainer(container.value, date));
const visibleKeys = persistentVisibleKeysForContainer(container);
const viewer = useTemplateRef<ComponentExposed<typeof ViewerWithSource>>("viewer");
provideLoggingContext(
toRef(() => [container.value]),
{ showContainerName: false, showHostname: false, historical: true },
);
</script>

View File

@@ -24,7 +24,7 @@
<div class="flex-none">
<div class="dropdown dropdown-end dropdown-hover">
<label tabindex="0" class="btn btn-square btn-ghost btn-sm">
<ph:dots-three-vertical-bold />
<ion:ellipsis-vertical />
</label>
<ul
tabindex="0"

View File

@@ -1,5 +1,6 @@
<template>
<LogItem :logEntry @click="containers.length > 0 && showDrawer(LogDetails, { entry: logEntry })" class="clickable">
<LogItem :logEntry>
<div @click="containers.length > 0 && showDrawer(LogDetails, { entry: logEntry })" class="cursor-pointer">
<ul class="space-x-4" @click="preventDefaultOnLinks">
<li v-for="(value, name) in validValues" :key="name" class="inline-flex">
<span class="text-light">{{ name }}=</span>
@@ -11,6 +12,7 @@
</li>
<li class="text-light" v-if="Object.keys(validValues).length === 0">all values are hidden</li>
</ul>
</div>
</LogItem>
</template>
<script lang="ts" setup>

View File

@@ -65,12 +65,29 @@ describe("<ContainerEventSource />", () => {
template: "Test from createLogEventSource",
},
},
{
name: "/container/[id].time.[datetime]",
path: "/container/:id/time/:datetime",
component: {
template: "Test from createLogEventSource",
},
},
],
});
return mount(Component, {
global: {
plugins: [router, createTestingPinia({ createSpy: vi.fn }), createI18n({})],
plugins: [
router,
createTestingPinia({
createSpy: vi.fn,
stubActions: false,
initialState: {
container: { containers: [{ id: "abc", image: "test:v123", host: "localhost" }] },
},
}),
createI18n({}),
],
components: {
LogViewer,
},
@@ -84,6 +101,7 @@ describe("<ContainerEventSource />", () => {
streamConfig: reactive({ stdout: true, stderr: true }),
hasComplexLogs: ref(false),
levels: new Set<Level>(["info"]),
historical: ref(false),
},
},
},
@@ -138,7 +156,7 @@ describe("<ContainerEventSource />", () => {
const wrapper = createLogEventSource();
sources[sourceUrl].emitOpen();
sources[sourceUrl].emitMessage({
data: `{"ts":1560336942459, "m":"This is a message.", "id":1, "rm": "This is a message."}`,
data: `{"ts":1560336942459, "m":"This is a message.", "id":1, "rm": "This is a message.", "c": "abc"}`,
});
vi.runAllTimers();
@@ -154,7 +172,7 @@ describe("<ContainerEventSource />", () => {
const wrapper = createLogEventSource();
sources[sourceUrl].emitOpen();
sources[sourceUrl].emitMessage({
data: `{"ts":1560336942459, "m":"This is a message.", "id":1, "rm": "This is a message."}`,
data: `{"ts":1560336942459, "m":"This is a message.", "id":1, "rm": "This is a message.", "c": "abc"}`,
});
vi.runAllTimers();
@@ -167,7 +185,7 @@ describe("<ContainerEventSource />", () => {
const wrapper = createLogEventSource({ hourStyle: "12" });
sources[sourceUrl].emitOpen();
sources[sourceUrl].emitMessage({
data: `{"ts":1560336942459, "m":"foo bar", "id":1, "rm": "foo bar"}`,
data: `{"ts":1560336942459, "m":"foo bar", "id":1, "rm": "foo bar", "c": "abc"}`,
});
vi.runAllTimers();
@@ -180,7 +198,7 @@ describe("<ContainerEventSource />", () => {
const wrapper = createLogEventSource({ hourStyle: "24" });
sources[sourceUrl].emitOpen();
sources[sourceUrl].emitMessage({
data: `{"ts":1560336942459, "m":"foo bar", "id":1}`,
data: `{"ts":1560336942459, "m":"foo bar", "id":1, "c": "abc"}`,
});
vi.runAllTimers();

View File

@@ -10,18 +10,21 @@
{{ $t("label.no-logs") }}
</div>
<slot :messages="messages" v-else></slot>
<IndeterminateBar :color />
<IndeterminateBar :color v-if="!historical" />
</template>
<script lang="ts" setup generic="T">
import { LogStreamSource } from "@/composable/eventStreams";
const route = useRoute();
const { entity, streamSource } = $defineProps<{
streamSource: (t: Ref<T>) => LogStreamSource;
entity: T;
}>();
const { messages, opened, loading, error, eventSourceURL } = streamSource(toRef(() => entity));
const { historical } = useLoggingContext();
const { messages, opened, loading, error } = streamSource(toRef(() => entity));
const color = computed(() => {
if (error.value) return "error";
@@ -38,7 +41,18 @@ defineExpose({
clear: () => (messages.value = []),
});
const sizes = computedWithControl(eventSourceURL, () => {
if (historical.value && route.query.logId) {
watchOnce(messages, async () => {
await nextTick();
document.getElementById(route.query.logId as string)?.scrollIntoView({ behavior: "instant", block: "center" });
});
}
const sizes = ref<string[]>([]);
watch(
opened,
(value) => {
if (value) return;
const sizeOptions = [
"w-2/12",
"w-3/12",
@@ -52,11 +66,11 @@ const sizes = computedWithControl(eventSourceURL, () => {
"w-11/12",
"w-full",
];
const result = [];
const iterations = 18;
for (let i = 0; i < iterations; i++) {
result.push(sizeOptions[Math.floor(Math.random() * sizeOptions.length)]);
}
return result;
});
sizes.value = Array.from({ length: 18 }, () => sizeOptions[Math.floor(Math.random() * sizeOptions.length)]);
},
{
flush: "sync",
immediate: true,
},
);
</script>

View File

@@ -1,6 +1,6 @@
<template>
<div ref="root" class="flex min-h-[1px] flex-1 content-center justify-center p-2">
<span class="loading loading-bars loading-md text-primary" v-show="isLoading"></span>
<div ref="root" class="flex min-h-[1px] flex-1 content-center justify-center">
<span class="loading loading-bars loading-md text-primary m-2" v-show="isLoading"></span>
</div>
</template>
<script lang="ts" setup>
@@ -22,7 +22,9 @@ useIntersectionObserver(root, async (entries) => {
await logEntry.loadMore();
isLoading.value = false;
await nextTick();
if (logEntry.rememberScrollPosition) {
scrollingParent.scrollTop += scrollingParent.scrollHeight - previousHeight;
}
});
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div
class="dropdown dropdown-start dropdown-hover font-sans group-[.compact]:absolute group-[.compact]:-left-0.5"
v-show="container"
>
<button tabindex="0" class="btn btn-square btn-ghost btn-xs -mr-1 -ml-3 opacity-0 group-hover/entry:opacity-100">
<ion:ellipsis-vertical />
</button>
<ul
tabindex="0"
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 -mr-1 -ml-3 w-52 border p-1 text-sm shadow-sm"
@click="hideMenu"
>
<li>
<a v-if="isSupported" @click="copyLogMessage()">
<material-symbols:content-copy />
Copy line
</a>
<a v-if="isSupported" @click="copyPermalink()">
<material-symbols:link />
Copy permalink
</a>
<router-link
v-if="isSearching"
@click="resetSearch()"
:to="{
name: '/container/[id].time.[datetime]',
params: { id: container.id, datetime: logEntry.date.toISOString() },
query: { logId: logEntry.id },
}"
>
<material-symbols:eye-tracking />
See log in context
</router-link>
<a @click="showDrawer(LogDetails, { entry: logEntry })" v-if="logEntry instanceof ComplexLogEntry">
<material-symbols:code-blocks-rounded />
Show details
</a>
</li>
</ul>
</div>
</template>
<script lang="ts" setup>
import { Container } from "@/models/Container";
import { LogEntry, SimpleLogEntry, ComplexLogEntry, JSONObject } from "@/models/LogEntry";
import LogDetails from "./LogDetails.vue";
const { logEntry, container } = defineProps<{
logEntry: LogEntry<string | JSONObject>;
container: Container;
}>();
const { showToast } = useToast();
const showDrawer = useDrawer();
const router = useRouter();
const { isSearching, resetSearch } = useSearchFilter();
const { copy, isSupported, copied } = useClipboard();
const { t } = useI18n();
async function copyLogMessage() {
if (logEntry instanceof ComplexLogEntry) {
await copy(logEntry.rawMessage);
} else if (logEntry instanceof SimpleLogEntry) {
await copy(logEntry.message);
}
if (copied.value) {
showToast(
{
title: t("toasts.copied.title"),
message: t("toasts.copied.message"),
type: "info",
},
{ expire: 2000 },
);
}
}
async function copyPermalink() {
const url = router.resolve({
name: "/container/[id].time.[datetime]",
params: { id: container.id, datetime: logEntry.date.toISOString() },
query: { logId: logEntry.id },
}).href;
const resolved = new URL(url, window.location.origin);
await copy(resolved.href);
if (copied.value) {
showToast(
{
title: t("toasts.copied.title"),
message: t("toasts.copied.message"),
type: "info",
},
{ expire: 2000 },
);
}
}
function hideMenu(e: MouseEvent) {
if (e.target instanceof HTMLAnchorElement) {
setTimeout(() => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}, 50);
}
}
</script>

View File

@@ -32,7 +32,7 @@
<UseClipboard v-slot="{ copy, copied }" :source="entry.rawMessage">
<button class="swap outline-hidden" @click="copy()" :class="{ 'hover:swap-active': copied }">
<mdi:check class="swap-on" />
<mdi:content-copy class="swap-off" />
<material-symbols:content-copy class="swap-off" />
</button>
</UseClipboard>
</div>

View File

@@ -1,16 +1,23 @@
<template>
<div class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
<LogActions :logEntry :container />
<LogStd :std="logEntry.std" class="shrink-0 select-none" v-if="showStd" />
<div class="flex gap-x-2 gap-y-1 group-[.compact]:gap-y-0 has-[>_*:nth-of-type(2)]:flex-col-reverse md:flex-row!">
<RandomColorTag class="w-30 shrink-0 select-none md:w-40" :value="host.name" v-if="showHostname" />
<RandomColorTag
v-if="showContainerName"
class="w-30 shrink-0 select-none group-[.compact]:flex-1 md:w-40"
:value="container.name"
v-if="showContainerName"
truncateRight
/>
<LogDate :date="logEntry.date" v-if="showTimestamp" class="shrink-0 select-none" />
<LogDate
v-if="showTimestamp"
:date="logEntry.date"
class="shrink-0 select-none"
:class="{ 'bg-secondary': route.query.logId === logEntry.id.toString() }"
/>
</div>
<LogLevel
class="flex select-none"
@@ -33,4 +40,6 @@ const { hosts } = useHosts();
const container = currentContainer(toRef(() => logEntry.containerID));
const host = computed(() => hosts.value[container.value.host]);
const route = useRoute();
</script>

View File

@@ -4,7 +4,7 @@
v-for="item in messages"
ref="list"
:key="item.id"
:data-key="item.id"
:id="item.id.toString()"
:data-time="item.date.getTime()"
class="group/entry"
>
@@ -63,7 +63,7 @@ ul {
monospace;
> li {
@apply has-[.clickable]:hover:bg-primary/10 flex px-2 py-1 break-words last:snap-end odd:bg-gray-400/[0.07] has-[.clickable]:cursor-pointer md:px-4;
@apply flex px-2 py-1 break-words last:snap-end odd:bg-gray-400/[0.07] md:px-4;
&:last-child {
scroll-margin-block-end: 5rem;
}

View File

@@ -1,44 +0,0 @@
<template>
<div class="flex gap-2">
<div
class="flex min-w-[0.98rem] items-start justify-end align-bottom hover:cursor-pointer"
v-if="isSupported"
:title="t('log_actions.copy_log')"
>
<span
class="text-primary rounded-sm bg-slate-800/60 px-1.5 py-1 hover:bg-slate-700"
@click.prevent="copyLogMessageToClipBoard()"
>
<carbon:copy-file />
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { LogEntry, JSONObject } from "@/models/LogEntry";
const { message } = defineProps<{
message: () => string;
logEntry: LogEntry<string | JSONObject>;
}>();
const { showToast } = useToast();
const { copy, isSupported, copied } = useClipboard();
const { t } = useI18n();
async function copyLogMessageToClipBoard() {
await copy(message());
if (copied.value) {
showToast(
{
title: t("toasts.copied.title"),
message: t("toasts.copied.message"),
type: "info",
},
{ expire: 2000 },
);
}
}
</script>

View File

@@ -13,30 +13,6 @@ const props = defineProps<{
const { messages, visibleKeys } = toRefs(props);
const { filteredPayload } = useVisibleFilter(visibleKeys);
const { debouncedSearchFilter } = useSearchFilter();
const { streamConfig } = useLoggingContext();
const visibleMessages = filteredPayload(messages);
const router = useRouter();
watchEffect(() => {
const query = {} as Record<string, string>;
if (debouncedSearchFilter.value !== "") {
query.search = debouncedSearchFilter.value;
}
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></style>

View File

@@ -4,7 +4,11 @@
<carbon:circle-solid class="text-red w-2.5" v-if="streamConfig.stderr" />
<carbon:circle-solid class="text-blue w-2.5" v-if="streamConfig.stdout" />
</label>
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 z-50 w-52 p-1 shadow-sm" @click="hideMenu">
<ul
tabindex="0"
class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 w-52 border p-1 shadow-sm"
@click="hideMenu"
>
<li>
<a @click.prevent="clear()">
<octicon:trash-24 /> {{ $t("toolbar.clear") }}

View File

@@ -4,19 +4,11 @@
class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap"
v-html="colorize(logEntry.message)"
></div>
<LogMessageActions
class="absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100"
:message="() => stripAnsi(logEntry.rawMessage)"
:log-entry="logEntry"
v-if="containers.length > 0"
/>
</LogItem>
</template>
<script lang="ts" setup>
import { SimpleLogEntry } from "@/models/LogEntry";
import AnsiConvertor from "ansi-to-html";
import stripAnsi from "strip-ansi";
const { containers } = useLoggingContext();
const ansiConvertor = new AnsiConvertor({
escapeXML: false,

View File

@@ -2,11 +2,25 @@
exports[`<ContainerEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
"<ul data-v-cf9ff940="" class="group pt-4 medium" data-logs="" show-container-name="false">
<li data-v-cf9ff940="" data-key="1560336942709" data-time="1560336942709" class="group/entry">
<div data-v-cf9ff940="" class="flex min-h-[1px] flex-1 content-center justify-center p-2"><span class="loading loading-bars loading-md text-primary" style="display: none;"></span></div>
<li data-v-cf9ff940="" id="1560336942709" data-time="1560336942709" class="group/entry">
<div data-v-cf9ff940="" class="flex min-h-[1px] flex-1 content-center justify-center"><span class="loading loading-bars loading-md text-primary m-2" style="display: none;"></span></div>
</li>
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
<div class="dropdown dropdown-start dropdown-hover font-sans group-[.compact]:absolute group-[.compact]:-left-0.5"><button tabindex="0" class="btn btn-square btn-ghost btn-xs -mr-1 -ml-3 opacity-0 group-hover/entry:opacity-100"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
</svg></button>
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 -mr-1 -ml-3 w-52 border p-1 text-sm shadow-sm">
<li>
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if-->
</li>
</ul>
</div>
<!--v-if-->
<div class="flex gap-x-2 gap-y-1 group-[.compact]:gap-y-0 has-[>_*:nth-of-type(2)]:flex-col-reverse md:flex-row!">
<!--v-if-->
@@ -17,9 +31,6 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
</div>
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
<div class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
<div class="flex gap-2 absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100">
<!--v-if-->
</div>
</div>
</li>
</ul>"
@@ -27,11 +38,25 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
exports[`<ContainerEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
"<ul data-v-cf9ff940="" class="group pt-4 medium" data-logs="" show-container-name="false">
<li data-v-cf9ff940="" data-key="1560336942709" data-time="1560336942709" class="group/entry">
<div data-v-cf9ff940="" class="flex min-h-[1px] flex-1 content-center justify-center p-2"><span class="loading loading-bars loading-md text-primary" style="display: none;"></span></div>
<li data-v-cf9ff940="" id="1560336942709" data-time="1560336942709" class="group/entry">
<div data-v-cf9ff940="" class="flex min-h-[1px] flex-1 content-center justify-center"><span class="loading loading-bars loading-md text-primary m-2" style="display: none;"></span></div>
</li>
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
<div class="dropdown dropdown-start dropdown-hover font-sans group-[.compact]:absolute group-[.compact]:-left-0.5"><button tabindex="0" class="btn btn-square btn-ghost btn-xs -mr-1 -ml-3 opacity-0 group-hover/entry:opacity-100"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
</svg></button>
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 -mr-1 -ml-3 w-52 border p-1 text-sm shadow-sm">
<li>
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if-->
</li>
</ul>
</div>
<!--v-if-->
<div class="flex gap-x-2 gap-y-1 group-[.compact]:gap-y-0 has-[>_*:nth-of-type(2)]:flex-col-reverse md:flex-row!">
<!--v-if-->
@@ -42,9 +67,6 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
</div>
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
<div class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">foo bar</div>
<div class="flex gap-2 absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100">
<!--v-if-->
</div>
</div>
</li>
</ul>"
@@ -52,11 +74,25 @@ exports[`<ContainerEventSource /> > render html correctly > should render dates
exports[`<ContainerEventSource /> > render html correctly > should render messages 1`] = `
"<ul data-v-cf9ff940="" class="group pt-4 medium" data-logs="" show-container-name="false">
<li data-v-cf9ff940="" data-key="1560336942709" data-time="1560336942709" class="group/entry">
<div data-v-cf9ff940="" class="flex min-h-[1px] flex-1 content-center justify-center p-2"><span class="loading loading-bars loading-md text-primary" style="display: none;"></span></div>
<li data-v-cf9ff940="" id="1560336942709" data-time="1560336942709" class="group/entry">
<div data-v-cf9ff940="" class="flex min-h-[1px] flex-1 content-center justify-center"><span class="loading loading-bars loading-md text-primary m-2" style="display: none;"></span></div>
</li>
<li data-v-cf9ff940="" data-key="1" data-time="1560336942459" class="group/entry">
<li data-v-cf9ff940="" id="1" data-time="1560336942459" class="group/entry">
<div data-v-cf9ff940="" class="relative flex w-full items-start gap-x-2 group-[.compact]:items-stretch">
<div class="dropdown dropdown-start dropdown-hover font-sans group-[.compact]:absolute group-[.compact]:-left-0.5"><button tabindex="0" class="btn btn-square btn-ghost btn-xs -mr-1 -ml-3 opacity-0 group-hover/entry:opacity-100"><svg viewBox="0 0 512 512" width="1.2em" height="1.2em">
<circle cx="256" cy="256" r="48" fill="currentColor"></circle>
<circle cx="256" cy="416" r="48" fill="currentColor"></circle>
<circle cx="256" cy="96" r="48" fill="currentColor"></circle>
</svg></button>
<ul tabindex="0" class="menu dropdown-content rounded-box bg-base-200 border-base-content/20 z-50 -mr-1 -ml-3 w-52 border p-1 text-sm shadow-sm">
<li>
<!--v-if-->
<!--v-if-->
<!--v-if-->
<!--v-if-->
</li>
</ul>
</div>
<!--v-if-->
<div class="flex gap-x-2 gap-y-1 group-[.compact]:gap-y-0 has-[>_*:nth-of-type(2)]:flex-col-reverse md:flex-row!">
<!--v-if-->
@@ -67,9 +103,6 @@ exports[`<ContainerEventSource /> > render html correctly > should render messag
</div>
<div data-v-e625cddd="" class="mt-1.5 size-2.5 flex-none rounded-lg flex select-none"></div>
<div class="[word-break:break-word] whitespace-pre-wrap group-[.disable-wrap]:whitespace-nowrap">This is a message.</div>
<div class="flex gap-2 absolute -right-1 opacity-0 transition-opacity delay-150 duration-250 group-hover/entry:opacity-100">
<!--v-if-->
</div>
</div>
</li>
</ul>"
@@ -84,6 +117,7 @@ LoadMoreLogEntry {
"level": undefined,
"loader": [Function],
"rawMessage": "info",
"rememberScrollPosition": true,
"std": "stderr",
}
`;

View File

@@ -25,7 +25,7 @@
<div ref="scrollObserver" class="h-px"></div>
</main>
<div class="mr-16 text-right">
<div class="mr-16 text-right" v-if="!historical">
<transition name="fade">
<button
class="btn btn-primary text-primary-content fixed bottom-8 rounded-sm p-3 shadow-sm transition-colors"
@@ -49,8 +49,8 @@ const scrollableContent = ref<HTMLElement>();
const scrollContext = provideScrollContext();
const { loadingMore } = useLoggingContext();
const { loadingMore, historical } = useLoggingContext();
if (!historical.value) {
useIntersectionObserver(scrollObserver, ([entry]) => (scrollContext.paused = entry.intersectionRatio == 0), {
threshold: [0, 1],
rootMargin: "40px 0px",
@@ -71,6 +71,7 @@ useMutationObserver(
},
{ childList: true, subtree: true },
);
}
function scrollToBottom(behavior: "auto" | "smooth" = "auto") {
scrollObserver.value?.scrollIntoView({ behavior });

View File

@@ -23,8 +23,7 @@ function parseMessage(data: string): LogEntry<string | JSONObject> {
export function useContainerStream(container: Ref<Container>): LogStreamSource {
const url = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs/stream`);
const loadMoreUrl = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs`);
return useLogStream(url, loadMoreUrl);
return useLogStream(url, container);
}
export function useHostStream(host: Ref<Host>): LogStreamSource {
@@ -54,7 +53,7 @@ export function useServiceStream(service: Ref<Service>): LogStreamSource {
export type LogStreamSource = ReturnType<typeof useLogStream>;
function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
function useLogStream(url: Ref<string>, container?: Ref<Container>) {
const messages: ShallowRef<LogEntry<string | JSONObject>[]> = shallowRef([]);
const buffer: ShallowRef<LogEntry<string | JSONObject>[]> = shallowRef([]);
const opened = ref(false);
@@ -93,7 +92,7 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
// sort the buffer the very first time because of multiple logs in parallel
buffer.value.sort((a, b) => a.date.getTime() - b.date.getTime());
if (loadMoreUrl) {
if (container) {
const loadMoreItem = new LoadMoreLogEntry(new Date(), loadOlderLogs);
messages.value = [loadMoreItem];
}
@@ -182,42 +181,9 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
watch(urlWithParams, () => connect(), { immediate: true });
async function loadBetween(from: Date, to: Date, lastSeenId: number, minimum: number = 0) {
if (!loadMoreUrl) throw new Error("No loadMoreUrl");
const abortController = new AbortController();
const signal = abortController.signal;
if (loadingMore.value) throw new Error("Already loading");
try {
loadingMore.value = true;
const urlWithMoreParams = computed(() => {
const loadMoreParams = new URLSearchParams(params.value);
loadMoreParams.append("from", from.toISOString());
loadMoreParams.append("to", to.toISOString());
if (minimum > 0) {
loadMoreParams.append("minimum", String(minimum));
}
loadMoreParams.append("lastSeenId", String(lastSeenId));
return withBase(`${loadMoreUrl!.value}?${loadMoreParams.toString()}`);
});
const stopWatcher = watchOnce(urlWithMoreParams, () => abortController.abort("stream changed"));
const logs = await (await fetch(urlWithMoreParams.value, { signal })).text();
stopWatcher();
return {
logs: logs
.trim()
.split("\n")
.map((line) => parseMessage(line)),
signal,
};
} finally {
loadingMore.value = false;
}
}
async function loadOlderLogs(entry: LoadMoreLogEntry) {
if (!loadMoreUrl) throw new Error("No loadMoreUrl");
if (!(messages.value[0] instanceof LoadMoreLogEntry)) throw new Error("No loadMoreLogEntry on first item");
if (!container) throw new Error("No container");
const [loader, ...existingLogs] = messages.value;
const to = existingLogs[0].date;
@@ -226,27 +192,37 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
const delta = to.getTime() - last.getTime();
const from = new Date(to.getTime() + delta);
try {
const { logs: newLogs, signal } = await loadBetween(from, to, lastSeenId, 100);
loadingMore.value = true;
const { logs: newLogs, signal } = await loadBetween(container, params, from, to, {
min: 100,
lastSeenId,
});
if (newLogs && signal.aborted === false) {
messages.value = [loader, ...newLogs, ...existingLogs];
}
} catch (error) {
console.error(error);
} finally {
loadingMore.value = false;
}
}
async function loadSkippedLogs(entry: SkippedLogsEntry) {
if (!loadMoreUrl) throw new Error("No loadMoreUrl");
if (!container) throw new Error("No container");
const from = entry.firstSkipped.date;
const to = entry.lastSkippedLog.date;
const lastSeenId = entry.lastSkippedLog.id;
try {
const { logs, signal } = await loadBetween(from, to, lastSeenId);
loadingMore.value = true;
const { logs, signal } = await loadBetween(container, params, from, to, { lastSeenId });
if (logs && signal.aborted === false) {
messages.value = messages.value.slice(logs.length).flatMap((log) => (log === entry ? logs : [log]));
}
} catch (error) {
console.error(error);
} finally {
loadingMore.value = false;
}
}
@@ -260,11 +236,49 @@ function useLogStream(url: Ref<string>, loadMoreUrl?: Ref<string>) {
return {
messages,
loadOlderLogs,
hasComplexLogs,
opened,
error,
loading,
eventSourceURL: urlWithParams,
};
}
export async function loadBetween(
container: Ref<Container>,
params: Ref<URLSearchParams>,
from: Date,
to: Date,
{ lastSeenId, min, maxStart }: { lastSeenId?: number; min?: number; maxStart?: number } = {},
) {
const url = computed(() => `/api/hosts/${container.value.host}/containers/${container.value.id}/logs`);
const abortController = new AbortController();
const signal = abortController.signal;
const urlWithMoreParams = computed(() => {
const loadMoreParams = new URLSearchParams(params.value);
loadMoreParams.append("from", from.toISOString());
loadMoreParams.append("to", to.toISOString());
if (min) {
loadMoreParams.append("min", String(min));
}
if (maxStart) {
loadMoreParams.append("maxStart", String(maxStart));
}
if (lastSeenId) {
loadMoreParams.append("lastSeenId", String(lastSeenId));
}
return withBase(`${url.value}?${loadMoreParams.toString()}`);
});
const stopWatcher = watchOnce(urlWithMoreParams, () => abortController.abort("stream changed"));
const logs = await (await fetch(urlWithMoreParams.value, { signal })).text();
stopWatcher();
if (!logs) return { logs: [], signal };
return {
logs: logs
.trim()
.split("\n")
.map((line) => parseMessage(line)),
signal,
};
}

View File

@@ -0,0 +1,131 @@
import { HistoricalContainer } from "@/models/Container";
import { JSONObject, LoadMoreLogEntry, LogEntry } from "@/models/LogEntry";
import { ShallowRef } from "vue";
import { loadBetween } from "@/composable/eventStreams";
export function useHistoricalContainerLog(historicalContainer: Ref<HistoricalContainer>): LogStreamSource {
const messages: ShallowRef<LogEntry<string | JSONObject>[]> = shallowRef([]);
const opened = ref(false);
const loading = ref(true);
const error = ref(false);
const container = toRef(() => historicalContainer.value.container);
const { streamConfig, levels, loadingMore } = useLoggingContext();
const { isSearching, debouncedSearchFilter } = useSearchFilter();
const params = computed(() => {
const params = new URLSearchParams();
if (streamConfig.value.stdout) params.append("stdout", "1");
if (streamConfig.value.stderr) params.append("stderr", "1");
if (isSearching.value) params.append("filter", debouncedSearchFilter.value);
for (const level of levels.value) {
params.append("levels", level);
}
return params;
});
const route = useRoute();
async function loadLogs() {
loadingMore.value = true;
try {
const lastSeenId = route.query.logId ? +route.query.logId : undefined;
const [{ logs: before }, { logs: after }] = await Promise.all([
loadBetween(
container,
params,
new Date(historicalContainer.value.date.getTime() - 1000 * 60 * 5),
new Date(historicalContainer.value.date.getTime() + 1000),
{
min: 50,
lastSeenId,
},
),
loadBetween(container, params, historicalContainer.value.date, new Date(), {
maxStart: 50,
}),
]);
const loaderOlder = new LoadMoreLogEntry(new Date(), loadOlderLogs);
const loadNewer = new LoadMoreLogEntry(new Date(), loadNewerLogs, false);
messages.value = [loaderOlder, ...before, ...after, loadNewer];
loading.value = false;
opened.value = true;
} catch (error) {
console.error(error);
} finally {
loadingMore.value = false;
}
}
watchArray([params, container], loadLogs, { immediate: true });
async function loadOlderLogs(entry: LoadMoreLogEntry) {
loadingMore.value = true;
try {
const item = messages.value[1];
const { logs, signal } = await loadBetween(
container,
params,
new Date(item.date.getTime() - 1000 * 60 * 5),
item.date,
{
min: 200,
lastSeenId: item.id,
},
);
if (signal.aborted) {
return;
}
if (!logs.length) {
return;
}
const [loader, ...rest] = messages.value;
messages.value = [loader, ...logs, ...rest];
} catch (error) {
console.error(error);
} finally {
loadingMore.value = false;
}
}
async function loadNewerLogs(entry: LoadMoreLogEntry) {
loadingMore.value = true;
try {
const item = messages.value.at(-2)!;
const { logs, signal } = await loadBetween(
container,
params,
item.date,
new Date(item.date.getTime() + 1000 * 60 * 5),
{
maxStart: 100,
},
);
if (signal.aborted) {
return;
}
if (!logs.length) {
return;
}
const loader = messages.value.at(-1)!;
const rest = messages.value.slice(0, -1);
messages.value = [...rest, ...logs, loader];
} catch (error) {
console.error(error);
} finally {
loadingMore.value = false;
}
}
return {
messages,
opened,
error,
loading,
};
}

View File

@@ -9,6 +9,7 @@ type LogContext = {
levels: Set<Level>;
showContainerName: boolean;
showHostname: boolean;
historical: boolean;
};
export const allLevels: Level[] = ["info", "debug", "warn", "error", "fatal", "trace", "unknown"];
@@ -21,7 +22,7 @@ const stderr = searchParams.has("stderr") ? searchParams.get("stderr") === "true
export const provideLoggingContext = (
containers: Ref<Container[]>,
{ showContainerName = false, showHostname = false } = {},
{ showContainerName = false, showHostname = false, historical = false } = {},
) => {
provide(
loggingContextKey,
@@ -33,6 +34,7 @@ export const provideLoggingContext = (
levels: new Set<Level>(allLevels),
showContainerName,
showHostname,
historical,
}),
);
};
@@ -48,6 +50,7 @@ export const useLoggingContext = () => {
levels: new Set<Level>(allLevels),
showContainerName: false,
showHostname: false,
historical: false,
}),
);

View File

@@ -21,6 +21,13 @@ export class GroupedContainers {
) {}
}
export class HistoricalContainer {
constructor(
public readonly container: Container,
public readonly date: Date,
) {}
}
export class Container {
private _stat: Ref<Stat>;
private _name: string;

View File

@@ -193,6 +193,7 @@ export class LoadMoreLogEntry extends LogEntry<string> {
constructor(
date: Date,
private readonly loader: (i: LoadMoreLogEntry) => Promise<void>,
public readonly rememberScrollPosition: boolean = true,
) {
super("", "", date.getTime(), date, "stderr", "info");
}

View File

@@ -0,0 +1,36 @@
<template>
<Search />
<HistoricalContainerLog :id :date show-title :scrollable="pinnedLogs.length > 0" v-if="currentContainer" />
<div v-else-if="ready" class="hero bg-base-200 min-h-screen">
<div class="hero-content text-center">
<div class="max-w-md">
<p class="py-6 text-2xl font-bold">{{ $t("error.container-not-found") }}</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
const route = useRoute("/container/[id].time.[datetime]");
const id = toRef(() => route.params.id);
const date = toRef(() => new Date(route.params.datetime));
const containerStore = useContainerStore();
const currentContainer = containerStore.currentContainer(id);
const { ready } = storeToRefs(containerStore);
const pinnedLogsStore = usePinnedLogsStore();
const { pinnedLogs } = storeToRefs(pinnedLogsStore);
watchEffect(() => {
if (ready.value) {
if (currentContainer.value) {
setTitle(currentContainer.value.name);
} else {
setTitle("Not Found");
}
}
});
</script>
<route lang="yaml">
meta:
menu: host
</route>

View File

@@ -1,6 +1,6 @@
<template>
<Search />
<ContainerLog :id="id" :show-title="true" :scrollable="pinnedLogs.length > 0" v-if="currentContainer" />
<ContainerLog :id show-title :scrollable="pinnedLogs.length > 0" v-if="currentContainer" />
<div v-else-if="ready" class="hero bg-base-200 min-h-screen">
<div class="hero-content text-center">
<div class="max-w-md">

View File

@@ -21,6 +21,7 @@ declare module 'vue-router/auto-routes' {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/[...all]': RouteRecordInfo<'/[...all]', '/:all(.*)', { all: ParamValue<true> }, { all: ParamValue<false> }>,
'/container/[id]': RouteRecordInfo<'/container/[id]', '/container/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/container/[id].time.[datetime]': RouteRecordInfo<'/container/[id].time.[datetime]', '/container/:id/time/:datetime', { id: ParamValue<true>, datetime: ParamValue<true> }, { id: ParamValue<false>, datetime: ParamValue<false> }>,
'/group/[name]': RouteRecordInfo<'/group/[name]', '/group/:name', { name: ParamValue<true> }, { name: ParamValue<false> }>,
'/host/[id]': RouteRecordInfo<'/host/[id]', '/host/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
'/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -4,6 +4,7 @@ import (
"compress/gzip"
"context"
"errors"
"math"
"regexp"
"sort"
"strconv"
@@ -62,7 +63,6 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
return
}
buffer := utils.NewRingBuffer[*container.LogEvent](500)
delta := max(to.Sub(from), time.Second*3)
var regex *regexp.Regexp
@@ -82,8 +82,9 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
}
minimum := 0
if r.URL.Query().Has("minimum") {
minimum, err = strconv.Atoi(r.URL.Query().Get("minimum"))
buffer := utils.NewRingBuffer[*container.LogEvent](500)
if r.URL.Query().Has("min") {
minimum, err = strconv.Atoi(r.URL.Query().Get("min"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -93,6 +94,21 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
http.Error(w, errors.New("minimum must be between 0 and buffer size").Error(), http.StatusBadRequest)
return
}
buffer = utils.NewRingBuffer[*container.LogEvent](minimum)
}
maxStart := math.MaxInt
if r.URL.Query().Has("maxStart") {
maxStart, err = strconv.Atoi(r.URL.Query().Get("maxStart"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if maxStart < 1 || maxStart > buffer.Size {
http.Error(w, errors.New("invalid maxStart").Error(), http.StatusBadRequest)
return
}
}
levels := make(map[string]struct{})
@@ -120,7 +136,7 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
}
for {
if buffer.Len() > minimum {
if minimum > 0 && buffer.Len() >= minimum {
break
}
@@ -157,6 +173,10 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
break
}
if buffer.Len() >= maxStart {
break
}
support_web.EscapeHTMLValues(event)
buffer.Push(event)
}
@@ -166,13 +186,19 @@ func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request)
break
}
if minimum == 0 {
break
}
from = from.Add(-delta)
delta = delta * 2
}
log.Debug().Int("buffer_size", buffer.Len()).Msg("sending logs to client")
for _, event := range buffer.Data() {
data := buffer.Data()
for _, event := range data {
if err := encoder.Encode(event); err != nil {
log.Error().Err(err).Msg("error encoding log event")
return

View File

@@ -268,6 +268,7 @@ func Test_handler_between_dates_with_fill(t *testing.T) {
q.Add("stderr", "true")
q.Add("fill", "true")
q.Add("levels", "info")
q.Add("min", "10")
req.URL.RawQuery = q.Encode()
@@ -280,16 +281,22 @@ func Test_handler_between_dates_with_fill(t *testing.T) {
mockedClient.On("ContainerLogsBetweenDates", mock.Anything, id, from, to, container.STDALL).
Return(io.NopCloser(bytes.NewReader([]byte{})), nil).
Once()
mockedClient.On("ContainerLogsBetweenDates", mock.Anything, id, time.Date(2017, time.December, 31, 14, 0, 0, 0, time.UTC), to, container.STDALL).
Return(io.NopCloser(bytes.NewReader(data)), nil).
Once()
mockedClient.On("FindContainer", mock.Anything, id).Return(container.Container{ID: id}, nil)
mockedClient.On("Host").Return(container.Host{
ID: "localhost",
})
mockedClient.On("ContainerLogsBetweenDates", mock.Anything, id, time.Date(2017, time.December, 30, 18, 0, 0, 0, time.UTC), to, container.STDALL).
Return(io.NopCloser(bytes.NewReader(data)), nil).
Once()
mockedClient.On("FindContainer", mock.Anything, id).Return(container.Container{ID: id, Created: time.Date(2017, time.December, 31, 10, 0, 0, 0, time.UTC)}, nil)
mockedClient.On("Host").Return(container.Host{ID: "localhost"})
mockedClient.On("ListContainers", mock.Anything, mock.Anything).Return([]container.Container{
{ID: id, Name: "test", Host: "localhost", State: "running"},
}, nil)
mockedClient.On("ContainerEvents", mock.Anything, mock.AnythingOfType("chan<- container.ContainerEvent")).Return(nil)
handler := createDefaultHandler(mockedClient)

View File

@@ -124,7 +124,7 @@ log_actions:
toasts:
copied:
title: Copied
message: Log copied to clipboard
message: Copied to clipboard
analytics:
creating_table: Creating temporary table...
downloading: Fetching container logs... ({size})

View File

@@ -79,6 +79,7 @@
"devDependencies": {
"@apache-arrow/esnext-esm": "^20.0.0",
"@iconify-json/material-symbols-light": "^1.2.25",
"@iconify-json/ion": "^1.2.3",
"@iconify-json/ri": "^1.2.5",
"@pinia/testing": "^1.0.2",
"@playwright/test": "^1.52.0",

10
pnpm-lock.yaml generated
View File

@@ -153,6 +153,9 @@ importers:
'@apache-arrow/esnext-esm':
specifier: ^20.0.0
version: 20.0.0
'@iconify-json/ion':
specifier: ^1.2.3
version: 1.2.3
'@iconify-json/material-symbols-light':
specifier: ^1.2.25
version: 1.2.25
@@ -655,6 +658,9 @@ packages:
'@iconify-json/ic@1.2.2':
resolution: {integrity: sha512-QmjwS3lYiOmVWgTCEOTFyGODaR/+689+ajep/VsrCcsUN0Gdle5PmIcibDsdmRyrOsW/E77G41UUijdbjQUofw==}
'@iconify-json/ion@1.2.3':
resolution: {integrity: sha512-qV9zsuBFjCgU5WRFO2thhhmaw1wr1wpJMliuuwu7pOtFEEoMOPP45Q7edF+k8uYuouFq+94SlCMIsca+v9kt2g==}
'@iconify-json/material-symbols-light@1.2.25':
resolution: {integrity: sha512-lbejcqyI64qxfva20iirLKDMo+F2X6YIn0C3B3+st+1MMKJbnu/1kNTqoDBhpHrNmJzONCG2c1lf2h4GGJT9nA==}
@@ -4141,6 +4147,10 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/ion@1.2.3':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/material-symbols-light@1.2.25':
dependencies:
'@iconify/types': 2.0.0