feat: adds the ability to show a specific log from the past with a permanent link (#3958)
4
assets/auto-imports.d.ts
vendored
@@ -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']>
|
||||
|
||||
11
assets/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
63
assets/components/ContainerViewer/HistoricalContainerLog.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
113
assets/components/LogViewer/LogActions.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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") }}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
131
assets/composable/historicalLogs.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
36
assets/pages/container/[id].time.[datetime].vue
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
1
assets/typed-router.d.ts
vendored
@@ -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>>,
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||