Support for JSON logs (#1759)
* WIP for using json all the time * Updates to render * adds a new component for json * Updates styles * Adds nesting * Adds field list * Adds expanding * Adds new composable for event source * Creates an add button * Removes unused code * Adds and removes fields with defaults * Fixes jumping when adding new fields * Returns JSON correctly * Fixes little bugs * Fixes js tests * Adds vscode * Fixes json buffer error * Fixes extra line * Fixes tests * Fixes tests and adds support for search * Refactors visible payload keys to a composable * Fixes typescript errors and refactors * Fixes visible keys by ComputedRef<Ref> * Fixes search bugs * Updates tests * Fixes go tests * Fixes scroll view * Fixes vue tsc errors * Fixes EOF error * Fixes build error * Uses application/ld+json * Fixes arrays and records * Marks for json too
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ static
|
||||
dozzle
|
||||
coverage
|
||||
.pnpm-debug.log
|
||||
.vscode
|
||||
|
||||
@@ -107,7 +107,7 @@ function showFuzzySearch() {
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
function onResized(e) {
|
||||
function onResized(e: any) {
|
||||
if (e.length == 2) {
|
||||
menuWidth.value = e[0].size;
|
||||
}
|
||||
|
||||
2
assets/components.d.ts
vendored
2
assets/components.d.ts
vendored
@@ -13,8 +13,10 @@ declare module '@vue/runtime-core' {
|
||||
ContainerStat: typeof import('./components/ContainerStat.vue')['default']
|
||||
ContainerTitle: typeof import('./components/ContainerTitle.vue')['default']
|
||||
DropdownMenu: typeof import('./components/DropdownMenu.vue')['default']
|
||||
FieldList: typeof import('./components/FieldList.vue')['default']
|
||||
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
|
||||
InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default']
|
||||
JSONPayload: typeof import('./components/JSONPayload.vue')['default']
|
||||
LogActionsToolbar: typeof import('./components/LogActionsToolbar.vue')['default']
|
||||
LogContainer: typeof import('./components/LogContainer.vue')['default']
|
||||
LogEventSource: typeof import('./components/LogEventSource.vue')['default']
|
||||
|
||||
@@ -1,34 +1,28 @@
|
||||
<template>
|
||||
<div class="is-size-7 is-uppercase columns is-marginless is-mobile">
|
||||
<div class="is-size-7 is-uppercase columns is-marginless is-mobile" v-if="container.stat">
|
||||
<div class="column is-narrow has-text-weight-bold">
|
||||
{{ state }}
|
||||
{{ container.state }}
|
||||
</div>
|
||||
<div class="column is-narrow" v-if="stat.memoryUsage !== null">
|
||||
<div class="column is-narrow" v-if="container.stat.memoryUsage !== null">
|
||||
<span class="has-text-weight-light has-spacer">mem</span>
|
||||
<span class="has-text-weight-bold">
|
||||
{{ formatBytes(stat.memoryUsage) }}
|
||||
{{ formatBytes(container.stat.memoryUsage) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow" v-if="stat.cpu !== null">
|
||||
<div class="column is-narrow" v-if="container.stat.cpu !== null">
|
||||
<span class="has-text-weight-light has-spacer">load</span>
|
||||
<span class="has-text-weight-bold"> {{ stat.cpu }}% </span>
|
||||
<span class="has-text-weight-bold"> {{ container.stat.cpu }}% </span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ContainerStat } from "@/types/Container";
|
||||
import { PropType } from "vue";
|
||||
import { Container } from "@/types/Container";
|
||||
import { ComputedRef, inject } from "vue";
|
||||
import { formatBytes } from "@/utils";
|
||||
|
||||
defineProps({
|
||||
stat: {
|
||||
type: Object as PropType<ContainerStat>,
|
||||
required: true,
|
||||
},
|
||||
state: String,
|
||||
});
|
||||
const container = inject("container") as ComputedRef<Container>;
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -9,13 +9,8 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Container } from "@/types/Container";
|
||||
import { PropType } from "vue";
|
||||
defineProps({
|
||||
container: {
|
||||
type: Object as PropType<Container>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
import { inject, ComputedRef } from "vue";
|
||||
const container = inject("container") as ComputedRef<Container>;
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
84
assets/components/FieldList.vue
Normal file
84
assets/components/FieldList.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<ul v-if="expanded" ref="root">
|
||||
<li v-for="(value, name) in fields">
|
||||
<template v-if="isObject(value)">
|
||||
<span class="has-text-grey">{{ name }}=</span>
|
||||
<field-list
|
||||
:fields="value"
|
||||
:parent-key="parentKey.concat(name)"
|
||||
:visible-keys="visibleKeys"
|
||||
expanded
|
||||
></field-list>
|
||||
</template>
|
||||
<template v-else-if="Array.isArray(value)">
|
||||
<a @click="toggleField(name)"> {{ hasField(name) ? "remove" : "add" }} </a>
|
||||
<span class="has-text-grey">{{ name }}=</span>[
|
||||
<span class="has-text-weight-bold" v-for="(item, index) in value">
|
||||
{{ item }}
|
||||
<span v-if="index !== value.length - 1">,</span>
|
||||
</span>
|
||||
]
|
||||
</template>
|
||||
<template v-else>
|
||||
<a @click="toggleField(name)"> {{ hasField(name) ? "remove" : "add" }} </a>
|
||||
<span class="has-text-grey">{{ name }}=</span><span class="has-text-weight-bold">{{ value }}</span>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { arrayEquals, isObject } from "@/utils";
|
||||
import { nextTick, PropType, ref, toRaw } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Object as PropType<Record<string, any>>,
|
||||
required: true,
|
||||
},
|
||||
expanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
parentKey: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: [],
|
||||
},
|
||||
visibleKeys: {
|
||||
type: Array as PropType<string[][]>,
|
||||
default: [],
|
||||
},
|
||||
});
|
||||
|
||||
const root = ref<HTMLElement>();
|
||||
|
||||
async function toggleField(field: string) {
|
||||
const index = fieldIndex(field);
|
||||
|
||||
if (index > -1) {
|
||||
props.visibleKeys.splice(index, 1);
|
||||
} else {
|
||||
props.visibleKeys.push(props.parentKey.concat(field));
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
root.value?.scrollIntoView({
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
|
||||
function hasField(field: string) {
|
||||
return fieldIndex(field) > -1;
|
||||
}
|
||||
|
||||
function fieldIndex(field: string) {
|
||||
const path = props.parentKey.concat(field);
|
||||
return props.visibleKeys.findIndex((keys) => arrayEquals(toRaw(keys), toRaw(path)));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
ul {
|
||||
margin-left: 2em;
|
||||
}
|
||||
</style>
|
||||
@@ -22,7 +22,7 @@ const root = ref<HTMLElement>();
|
||||
const observer = new IntersectionObserver(async (entries) => {
|
||||
if (entries[0].intersectionRatio <= 0) return;
|
||||
if (props.onLoadMore && props.enabled) {
|
||||
const scrollingParent = root.value.closest("[data-scrolling]") || document.documentElement;
|
||||
const scrollingParent = root.value?.closest("[data-scrolling]") || document.documentElement;
|
||||
const previousHeight = scrollingParent.scrollHeight;
|
||||
isLoading.value = true;
|
||||
await props.onLoadMore();
|
||||
@@ -32,7 +32,7 @@ const observer = new IntersectionObserver(async (entries) => {
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => observer.observe(root.value));
|
||||
onMounted(() => observer.observe(root.value!));
|
||||
onUnmounted(() => observer.disconnect());
|
||||
</script>
|
||||
|
||||
|
||||
55
assets/components/JSONPayload.vue
Normal file
55
assets/components/JSONPayload.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<ul class="fields" @click="expanded = !expanded">
|
||||
<li v-for="(value, name) in logEntry.payload">
|
||||
<template v-if="value">
|
||||
<span class="has-text-grey">{{ name }}=</span>
|
||||
<span class="has-text-weight-bold" v-html="markSearch(value)"></span>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
<field-list :fields="logEntry.unfilteredPayload" :expanded="expanded" :visible-keys="visibleKeys"></field-list>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useSearchFilter } from "@/composables/search";
|
||||
import { VisibleLogEntry } from "@/types/VisibleLogEntry";
|
||||
|
||||
import { PropType, ref } from "vue";
|
||||
|
||||
const { markSearch } = useSearchFilter();
|
||||
|
||||
defineProps({
|
||||
logEntry: {
|
||||
type: Object as PropType<VisibleLogEntry>,
|
||||
required: true,
|
||||
},
|
||||
visibleKeys: {
|
||||
type: Array as PropType<string[][]>,
|
||||
default: [],
|
||||
},
|
||||
});
|
||||
|
||||
const expanded = ref(false);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.fields {
|
||||
display: inline-block;
|
||||
list-style: none;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
&::after {
|
||||
content: "expand json";
|
||||
color: var(--secondary-color);
|
||||
display: inline-block;
|
||||
margin-left: 0.5em;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -41,11 +41,11 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, PropType } from "vue";
|
||||
import { inject, onMounted, onUnmounted, PropType, ComputedRef } from "vue";
|
||||
import hotkeys from "hotkeys-js";
|
||||
import config from "@/stores/config";
|
||||
import { Container } from "@/types/Container";
|
||||
import { useSearchFilter } from "@/composables/search";
|
||||
import { Container } from "@/types/Container";
|
||||
|
||||
const { showSearch } = useSearchFilter();
|
||||
|
||||
@@ -56,10 +56,6 @@ const props = defineProps({
|
||||
type: Function as PropType<(e: Event) => void>,
|
||||
default: (e: Event) => {},
|
||||
},
|
||||
container: {
|
||||
type: Object as () => Container,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onHotkey = (event: Event) => {
|
||||
@@ -67,6 +63,8 @@ const onHotkey = (event: Event) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const container = inject("container") as ComputedRef<Container>;
|
||||
|
||||
onMounted(() => hotkeys("shift+command+l, shift+ctrl+l", onHotkey));
|
||||
onUnmounted(() => hotkeys.unbind("shift+command+l, shift+ctrl+l", onHotkey));
|
||||
</script>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
<template #header v-if="showTitle">
|
||||
<div class="mr-0 columns is-vcentered is-marginless is-hidden-mobile">
|
||||
<div class="column is-clipped is-paddingless">
|
||||
<container-title :container="container" @close="$emit('close')" />
|
||||
<container-title @close="$emit('close')" />
|
||||
</div>
|
||||
<div class="column is-narrow is-paddingless">
|
||||
<container-stat :stat="container.stat" :state="container.state" v-if="container.stat" />
|
||||
<container-stat v-if="container.stat" />
|
||||
</div>
|
||||
|
||||
<div class="mr-2 column is-narrow is-paddingless">
|
||||
<log-actions-toolbar :container="container" :onClearClicked="onClearClicked" />
|
||||
<log-actions-toolbar :onClearClicked="onClearClicked" />
|
||||
</div>
|
||||
<div class="mr-2 column is-narrow is-paddingless" v-if="closable">
|
||||
<button class="delete is-medium" @click="emit('close')"></button>
|
||||
@@ -18,13 +18,13 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ setLoading }">
|
||||
<log-viewer-with-source ref="viewer" :id="id" @loading-more="setLoading($event)" />
|
||||
<log-viewer-with-source ref="viewer" @loading-more="setLoading($event)" />
|
||||
</template>
|
||||
</scrollable-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, toRefs } from "vue";
|
||||
import { provide, ref, toRefs } from "vue";
|
||||
import LogViewerWithSource from "./LogViewerWithSource.vue";
|
||||
import { useContainerStore } from "@/stores/container";
|
||||
|
||||
@@ -54,6 +54,8 @@ const store = useContainerStore();
|
||||
|
||||
const container = store.currentContainer(id);
|
||||
|
||||
provide("container", container);
|
||||
|
||||
const viewer = ref<InstanceType<typeof LogViewerWithSource>>();
|
||||
|
||||
function onClearClicked() {
|
||||
|
||||
@@ -18,17 +18,6 @@ vi.mock("lodash.debounce", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/container", () => ({
|
||||
__esModule: true,
|
||||
useContainerStore() {
|
||||
return {
|
||||
currentContainer(id: Ref<string>) {
|
||||
return computed(() => ({ id: id.value }));
|
||||
},
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { base: "" },
|
||||
@@ -78,13 +67,16 @@ describe("<LogEventSource />", () => {
|
||||
components: {
|
||||
LogViewer,
|
||||
},
|
||||
provide: {
|
||||
container: computed(() => ({ id: "abc", image: "test:v123" })),
|
||||
},
|
||||
},
|
||||
slots: {
|
||||
default: `
|
||||
<template #scoped="params"><log-viewer :messages="params.messages"></log-viewer></template>
|
||||
`,
|
||||
},
|
||||
props: { id: "abc" },
|
||||
props: {},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,41 +103,11 @@ describe("<LogEventSource />", () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z "This is a message."`,
|
||||
data: `{"ts":1560336942.459, "m":"This is a message.", "id":1}`,
|
||||
});
|
||||
|
||||
const [message, _] = wrapper.vm.messages;
|
||||
const { key, ...messageWithoutKey } = message;
|
||||
|
||||
expect(key).toBe("2019-06-12T10:55:42.459034602Z");
|
||||
expect(messageWithoutKey).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should parse messages with loki's timestamp format", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({ data: `2020-04-27T12:35:43.272974324+02:00 xxxxx` });
|
||||
|
||||
const [message, _] = wrapper.vm.messages;
|
||||
const { key, ...messageWithoutKey } = message;
|
||||
|
||||
expect(key).toBe("2020-04-27T12:35:43.272974324+02:00");
|
||||
expect(messageWithoutKey).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("should pass messages to slot", async () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z "This is a message."`,
|
||||
});
|
||||
const [message, _] = wrapper.getComponent(LogViewer).vm.messages;
|
||||
|
||||
const { key, ...messageWithoutKey } = message;
|
||||
|
||||
expect(key).toBe("2019-06-12T10:55:42.459034602Z");
|
||||
|
||||
expect(messageWithoutKey).toMatchSnapshot();
|
||||
expect(message).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("render html correctly", () => {
|
||||
@@ -169,7 +131,7 @@ describe("<LogEventSource />", () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z "This is a message."`,
|
||||
data: `{"ts":1560336942.459, "m":"This is a message.", "id":1}`,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
@@ -180,7 +142,7 @@ describe("<LogEventSource />", () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z \x1b[30mblack\x1b[37mwhite`,
|
||||
data: '{"ts":1560336942.459,"m":"\\u001b[30mblack\\u001b[37mwhite", "id":1}',
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
@@ -191,7 +153,7 @@ describe("<LogEventSource />", () => {
|
||||
const wrapper = createLogEventSource();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z <test>foo bar</test>`,
|
||||
data: `{"ts":1560336942.459, "m":"<test>foo bar</test>", "id":1}`,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
@@ -202,7 +164,7 @@ describe("<LogEventSource />", () => {
|
||||
const wrapper = createLogEventSource({ hourStyle: "12" });
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T23:55:42.459034602Z <test>foo bar</test>`,
|
||||
data: `{"ts":1560336942.459, "m":"<test>foo bar</test>", "id":1}`,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
@@ -213,7 +175,7 @@ describe("<LogEventSource />", () => {
|
||||
const wrapper = createLogEventSource({ hourStyle: "24" });
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T23:55:42.459034602Z <test>foo bar</test>`,
|
||||
data: `{"ts":1560336942.459, "m":"<test>foo bar</test>", "id":1}`,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
@@ -224,10 +186,10 @@ describe("<LogEventSource />", () => {
|
||||
const wrapper = createLogEventSource({ searchFilter: "test" });
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitOpen();
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-11T10:55:42.459034602Z Foo bar`,
|
||||
data: `{"ts":1560336942.459, "m":"<test>foo bar</test>", "id":1}`,
|
||||
});
|
||||
sources["/api/logs/stream?id=abc&lastEventId="].emitMessage({
|
||||
data: `2019-06-12T10:55:42.459034602Z This is a test <hi></hi>`,
|
||||
data: `{"ts":1560336942.459, "m":"<test>test bar</test>", "id":1}`,
|
||||
});
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
@@ -1,133 +1,25 @@
|
||||
<template>
|
||||
<infinite-loader :onLoadMore="loadOlderLogs" :enabled="messages.length > 100"></infinite-loader>
|
||||
<infinite-loader :onLoadMore="fetchMore" :enabled="messages.length > 100"></infinite-loader>
|
||||
<slot :messages="messages"></slot>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { toRefs, ref, watch, onUnmounted } from "vue";
|
||||
import debounce from "lodash.debounce";
|
||||
import { useEventSource } from "@/composables/eventsource";
|
||||
import { Container } from "@/types/Container";
|
||||
import { inject, ComputedRef } from "vue";
|
||||
|
||||
import { LogEntry } from "@/types/LogEntry";
|
||||
import InfiniteLoader from "./InfiniteLoader.vue";
|
||||
import config from "@/stores/config";
|
||||
import { useContainerStore } from "@/stores/container";
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { id } = toRefs(props);
|
||||
const emit = defineEmits(["loading-more"]);
|
||||
const store = useContainerStore();
|
||||
const container = store.currentContainer(id);
|
||||
const container = inject("container") as ComputedRef<Container>;
|
||||
const { connect, messages, loadOlderLogs } = useEventSource(container);
|
||||
|
||||
const messages = ref<LogEntry[]>([]);
|
||||
const buffer = ref<LogEntry[]>([]);
|
||||
|
||||
function flushNow() {
|
||||
messages.value.push(...buffer.value);
|
||||
buffer.value = [];
|
||||
}
|
||||
|
||||
const flushBuffer = debounce(flushNow, 250, { maxWait: 1000 });
|
||||
|
||||
let es: EventSource | null = null;
|
||||
let lastEventId = "";
|
||||
|
||||
function connect({ clear } = { clear: true }) {
|
||||
es?.close();
|
||||
|
||||
if (clear) {
|
||||
flushBuffer.cancel();
|
||||
messages.value = [];
|
||||
buffer.value = [];
|
||||
lastEventId = "";
|
||||
}
|
||||
|
||||
es = new EventSource(`${config.base}/api/logs/stream?id=${props.id}&lastEventId=${lastEventId}`);
|
||||
es.addEventListener("container-stopped", () => {
|
||||
es?.close();
|
||||
es = null;
|
||||
buffer.value.push({
|
||||
event: "container-stopped",
|
||||
message: "Container stopped",
|
||||
date: new Date(),
|
||||
key: new Date().toString(),
|
||||
});
|
||||
flushBuffer();
|
||||
flushBuffer.flush();
|
||||
});
|
||||
es.addEventListener("error", (e) => console.error("EventSource failed: " + JSON.stringify(e)));
|
||||
es.onmessage = (e) => {
|
||||
lastEventId = e.lastEventId;
|
||||
if (e.data) {
|
||||
buffer.value.push(parseMessage(e.data));
|
||||
flushBuffer();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOlderLogs() {
|
||||
if (messages.value.length < 300) return;
|
||||
|
||||
emit("loading-more", true);
|
||||
const to = messages.value[0].date;
|
||||
const last = messages.value[299].date;
|
||||
const delta = to.getTime() - last.getTime();
|
||||
const from = new Date(to.getTime() + delta);
|
||||
const logs = await (
|
||||
await fetch(`${config.base}/api/logs?id=${props.id}&from=${from.toISOString()}&to=${to.toISOString()}`)
|
||||
).text();
|
||||
if (logs) {
|
||||
const newMessages = logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => parseMessage(line));
|
||||
messages.value.unshift(...newMessages);
|
||||
}
|
||||
emit("loading-more", false);
|
||||
}
|
||||
|
||||
function parseMessage(data: String): LogEntry {
|
||||
let i = data.indexOf(" ");
|
||||
if (i == -1) {
|
||||
i = data.length;
|
||||
}
|
||||
const key = data.substring(0, i);
|
||||
const date = new Date(key);
|
||||
const message = data.substring(i + 1);
|
||||
return { key, date, message };
|
||||
}
|
||||
|
||||
watch(
|
||||
() => container.value.state,
|
||||
(newValue, oldValue) => {
|
||||
console.log("LogEventSource: container changed", newValue, oldValue);
|
||||
if (newValue == "running" && newValue != oldValue) {
|
||||
buffer.value.push({
|
||||
event: "container-started",
|
||||
message: "Container started",
|
||||
date: new Date(),
|
||||
key: new Date().toString(),
|
||||
});
|
||||
connect({ clear: false });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (es) {
|
||||
es.close();
|
||||
}
|
||||
});
|
||||
|
||||
connect();
|
||||
watch(id, () => connect());
|
||||
const beforeLoading = () => emit("loading-more", true);
|
||||
const afterLoading = () => emit("loading-more", false);
|
||||
|
||||
defineExpose({
|
||||
clear: () => (messages.value = []),
|
||||
});
|
||||
|
||||
const fetchMore = () => loadOlderLogs({ beforeLoading, afterLoading });
|
||||
|
||||
connect();
|
||||
</script>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<ul class="events" ref="events" :class="{ 'disable-wrap': !softWrap, [size]: true }">
|
||||
<li
|
||||
v-for="(item, index) in filtered"
|
||||
:key="item.key"
|
||||
:data-key="item.key"
|
||||
:key="item.id"
|
||||
:data-key="item.id"
|
||||
:data-event="item.event"
|
||||
:class="{ selected: item.selected }"
|
||||
:class="{ selected: toRaw(item) === toRaw(lastSelectedItem) }"
|
||||
>
|
||||
<div class="line-options" v-show="isSearching()">
|
||||
<dropdown-menu :class="{ 'is-last': index === filtered.length - 1 }" class="is-top minimal">
|
||||
<a class="dropdown-item" @click="handleJumpLineSelected($event, item)" :href="`#${item.key}`">
|
||||
<a class="dropdown-item" @click="handleJumpLineSelected($event, item)" :href="`#${item.id}`">
|
||||
<div class="level is-justify-content-start">
|
||||
<div class="level-left">
|
||||
<div class="level-item">
|
||||
@@ -25,20 +25,27 @@
|
||||
</div>
|
||||
<div class="line">
|
||||
<span class="date" v-if="showTimestamp"> <relative-time :date="item.date"></relative-time></span>
|
||||
<span class="text" v-html="colorize(item.message)"></span>
|
||||
<JSONPayload :log-entry="item" :visible-keys="visibleKeys.value" v-if="item.hasPayload()"></JSONPayload>
|
||||
<span class="text" v-html="colorize(item.message)" v-else-if="item.message"></span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { PropType, ref, toRefs, watch } from "vue";
|
||||
import { ComputedRef, inject, PropType, ref, toRefs, watch, toRaw } from "vue";
|
||||
import { useRouteHash } from "@vueuse/router";
|
||||
import { size, showTimestamp, softWrap } from "@/composables/settings";
|
||||
import RelativeTime from "./RelativeTime.vue";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
import { VisibleLogEntry } from "@/types/VisibleLogEntry";
|
||||
import { LogEntry } from "@/types/LogEntry";
|
||||
import { useSearchFilter } from "@/composables/search";
|
||||
import { useVisibleFilter } from "@/composables/visible";
|
||||
import { Container } from "@/types/Container";
|
||||
import { persistentVisibleKeys } from "@/utils";
|
||||
|
||||
import RelativeTime from "./RelativeTime.vue";
|
||||
import AnsiConvertor from "ansi-to-html";
|
||||
import JSONPayload from "./JSONPayload.vue";
|
||||
|
||||
const props = defineProps({
|
||||
messages: {
|
||||
@@ -48,18 +55,22 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const ansiConvertor = new AnsiConvertor({ escapeXML: true });
|
||||
const { filteredMessages, resetSearch, markSearch, isSearching } = useSearchFilter();
|
||||
const colorize = (value: string) => markSearch(ansiConvertor.toHtml(value));
|
||||
|
||||
const { messages } = toRefs(props);
|
||||
const filtered = filteredMessages(messages);
|
||||
let visibleKeys = persistentVisibleKeys(inject("container") as ComputedRef<Container>);
|
||||
|
||||
const { filteredPayload } = useVisibleFilter(visibleKeys);
|
||||
const { filteredMessages, resetSearch, markSearch, isSearching } = useSearchFilter();
|
||||
|
||||
const visible = filteredPayload(messages);
|
||||
const filtered = filteredMessages(visible);
|
||||
|
||||
const events = ref<HTMLElement>();
|
||||
let lastSelectedItem: LogEntry | undefined = undefined;
|
||||
function handleJumpLineSelected(e: Event, item: LogEntry) {
|
||||
if (lastSelectedItem) {
|
||||
lastSelectedItem.selected = false;
|
||||
}
|
||||
lastSelectedItem = item;
|
||||
item.selected = true;
|
||||
let lastSelectedItem = ref<VisibleLogEntry>();
|
||||
|
||||
function handleJumpLineSelected(e: Event, item: VisibleLogEntry) {
|
||||
lastSelectedItem.value = item;
|
||||
resetSearch();
|
||||
}
|
||||
|
||||
@@ -84,6 +95,13 @@ watch(
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: pre-wrap;
|
||||
&::before {
|
||||
content: " ";
|
||||
}
|
||||
}
|
||||
|
||||
& > li {
|
||||
display: flex;
|
||||
word-wrap: break-word;
|
||||
@@ -167,13 +185,6 @@ watch(
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.text {
|
||||
white-space: pre-wrap;
|
||||
&::before {
|
||||
content: " ";
|
||||
}
|
||||
}
|
||||
|
||||
:deep(mark) {
|
||||
border-radius: 2px;
|
||||
background-color: var(--secondary-color);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<log-event-source ref="source" :id="id" #default="{ messages }" @loading-more="emit('loading-more', $event)">
|
||||
<log-event-source ref="source" #default="{ messages }" @loading-more="emit('loading-more', $event)">
|
||||
<log-viewer :messages="messages"></log-viewer>
|
||||
</log-event-source>
|
||||
</template>
|
||||
@@ -7,12 +7,6 @@
|
||||
<script lang="ts" setup>
|
||||
import LogViewer from "./LogViewer.vue";
|
||||
import { ref } from "vue";
|
||||
defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["loading-more"]);
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<div class="is-scrollbar-notification">
|
||||
<transition name="fade">
|
||||
<button class="button" :class="hasMore ? 'has-more' : ''" @click="scrollToBottom('instant')" v-show="paused">
|
||||
<button class="button" :class="hasMore ? 'has-more' : ''" @click="scrollToBottom()" v-show="paused">
|
||||
<mdi-light-chevron-double-down />
|
||||
</button>
|
||||
</transition>
|
||||
@@ -24,61 +24,51 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
props: {
|
||||
scrollable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
name: "ScrollableView",
|
||||
data() {
|
||||
return {
|
||||
paused: false,
|
||||
hasMore: false,
|
||||
loading: false,
|
||||
mutationObserver: null,
|
||||
intersectionObserver: null,
|
||||
};
|
||||
defineProps({
|
||||
scrollable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
mounted() {
|
||||
const { scrollableContent } = this.$refs;
|
||||
this.mutationObserver = new MutationObserver((e) => {
|
||||
if (!this.paused) {
|
||||
this.scrollToBottom("instant");
|
||||
} else {
|
||||
const record = e[e.length - 1];
|
||||
if (
|
||||
record.target.children[record.target.children.length - 1] == record.addedNodes[record.addedNodes.length - 1]
|
||||
) {
|
||||
this.hasMore = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
this.mutationObserver.observe(scrollableContent, { childList: true, subtree: true });
|
||||
});
|
||||
|
||||
this.intersectionObserver = new IntersectionObserver(
|
||||
(entries) => (this.paused = entries[0].intersectionRatio == 0),
|
||||
{ threshholds: [0, 1], rootMargin: "80px 0px" }
|
||||
);
|
||||
this.intersectionObserver.observe(this.$refs.scrollObserver);
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.mutationObserver.disconnect();
|
||||
this.intersectionObserver.disconnect();
|
||||
},
|
||||
methods: {
|
||||
scrollToBottom(behavior = "instant") {
|
||||
this.$refs.scrollObserver.scrollIntoView({ behavior });
|
||||
this.hasMore = false;
|
||||
},
|
||||
setLoading(loading) {
|
||||
this.loading = loading;
|
||||
},
|
||||
},
|
||||
};
|
||||
const paused = ref(false);
|
||||
const hasMore = ref(false);
|
||||
const loading = ref(false);
|
||||
const scrollObserver = ref<HTMLElement>();
|
||||
const scrollableContent = ref<HTMLElement>();
|
||||
|
||||
const mutationObserver = new MutationObserver((e) => {
|
||||
if (!paused.value) {
|
||||
scrollToBottom();
|
||||
} else {
|
||||
const record = e[e.length - 1];
|
||||
if (record.target.children[record.target.children.length - 1] == record.addedNodes[record.addedNodes.length - 1]) {
|
||||
hasMore.value = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const intersectionObserver = new IntersectionObserver((entries) => (paused.value = entries[0].intersectionRatio == 0), {
|
||||
threshholds: [0, 1],
|
||||
rootMargin: "80px 0px",
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
mutationObserver.observe(scrollableContent.value!, { childList: true, subtree: true });
|
||||
intersectionObserver.observe(scrollObserver.value!);
|
||||
});
|
||||
|
||||
function scrollToBottom(behavior: "auto" | "smooth" = "auto") {
|
||||
scrollObserver.value?.scrollIntoView({ behavior });
|
||||
hasMore.value = false;
|
||||
}
|
||||
|
||||
function setLoading(value: boolean) {
|
||||
loading.value = value;
|
||||
}
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
section {
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render dates with 12 hour style 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"2019-06-12T23:55:42.459034602Z\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T23:55:42.459034602Z\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
@@ -23,19 +23,19 @@ exports[`<LogEventSource /> > render html correctly > should render dates with 1
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T23:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 11:55:42 PM</time></span><span class=\\"text\\" data-v-cce5b553=\\"\\"><test>foo bar</test></span></div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-cce5b553=\\"\\"><test>foo bar</test></span></div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render dates with 24 hour style 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"2019-06-12T23:55:42.459034602Z\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T23:55:42.459034602Z\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
@@ -51,19 +51,19 @@ exports[`<LogEventSource /> > render html correctly > should render dates with 2
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T23:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 23:55:42</time></span><span class=\\"text\\" data-v-cce5b553=\\"\\"><test>foo bar</test></span></div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42</time></span><span class=\\"text\\" data-v-cce5b553=\\"\\"><test>foo bar</test></span></div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"2019-06-12T10:55:42.459034602Z\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T10:55:42.459034602Z\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
@@ -79,19 +79,19 @@ exports[`<LogEventSource /> > render html correctly > should render messages 1`]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-cce5b553=\\"\\">\\"This is a message.\\"</span></div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-cce5b553=\\"\\">This is a message.</span></div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with color 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"2019-06-12T10:55:42.459034602Z\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T10:55:42.459034602Z\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
@@ -114,12 +114,12 @@ exports[`<LogEventSource /> > render html correctly > should render messages wit
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with filter 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"2019-06-12T10:55:42.459034602Z\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown is-hoverable is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T10:55:42.459034602Z\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
@@ -135,19 +135,42 @@ exports[`<LogEventSource /> > render html correctly > should render messages wit
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-cce5b553=\\"\\">This is a <mark>test</mark> <hi></hi></span></div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-cce5b553=\\"\\"><<mark>test</mark>>foo bar</test></span></div>
|
||||
</li>
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
<path fill=\\"currentColor\\" d=\\"M334.627 16H48v480h424V153.373ZM440 464H80V48h241.373L440 166.627Z\\"></path>
|
||||
<path fill=\\"currentColor\\" d=\\"M239.861 152a95.861 95.861 0 1 0 53.624 175.284l68.03 68.029l22.627-22.626l-67.5-67.5A95.816 95.816 0 0 0 239.861 152ZM176 247.861a63.862 63.862 0 1 1 63.861 63.861A63.933 63.933 0 0 1 176 247.861Z\\"></path>
|
||||
</svg></div>
|
||||
</div>
|
||||
<div class=\\"level-right\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\">Jump to Context</div>
|
||||
</div>
|
||||
</div>
|
||||
</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=\\"line\\" data-v-cce5b553=\\"\\"><span class=\\"date\\" data-v-cce5b553=\\"\\"><time datetime=\\"2019-06-12T10:55:42.459Z\\" data-v-cce5b553=\\"\\">today at 10:55:42 AM</time></span><span class=\\"text\\" data-v-cce5b553=\\"\\"><<mark>test</mark>>test bar</test></span></div>
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > render html correctly > should render messages with html entities 1`] = `
|
||||
"<ul class=\\"events medium\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"2019-06-12T10:55:42.459034602Z\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<li data-key=\\"1\\" class=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"line-options\\" data-v-cce5b553=\\"\\" style=\\"display: none;\\">
|
||||
<div class=\\"dropdown is-hoverable is-last is-top minimal\\" data-v-539164cb=\\"\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-trigger\\" data-v-539164cb=\\"\\"><button class=\\"button\\" aria-haspopup=\\"true\\" aria-controls=\\"dropdown-menu\\" data-v-539164cb=\\"\\"><span class=\\"icon\\" data-v-539164cb=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 24 24\\" width=\\"1.2em\\" height=\\"1.2em\\" data-v-539164cb=\\"\\"><path fill=\\"currentColor\\" d=\\"M12 16a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2m0-6a2 2 0 0 1 2 2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2Z\\"></path></svg></span></button></div>
|
||||
<div class=\\"dropdown-menu\\" id=\\"dropdown-menu\\" role=\\"menu\\" data-v-539164cb=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#2019-06-12T10:55:42.459034602Z\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"dropdown-content\\" data-v-539164cb=\\"\\"><a class=\\"dropdown-item\\" href=\\"#1\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level is-justify-content-start\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-left\\" data-v-cce5b553=\\"\\">
|
||||
<div class=\\"level-item\\" data-v-cce5b553=\\"\\"><svg preserveAspectRatio=\\"xMidYMid meet\\" viewBox=\\"0 0 512 512\\" width=\\"1.2em\\" height=\\"1.2em\\" class=\\"mr-4\\" data-v-cce5b553=\\"\\">
|
||||
@@ -182,20 +205,8 @@ exports[`<LogEventSource /> > renders correctly 1`] = `
|
||||
exports[`<LogEventSource /> > should parse messages 1`] = `
|
||||
{
|
||||
"date": 2019-06-12T10:55:42.459Z,
|
||||
"message": "\\"This is a message.\\"",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > should parse messages with loki's timestamp format 1`] = `
|
||||
{
|
||||
"date": 2020-04-27T10:35:43.272Z,
|
||||
"message": "xxxxx",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`<LogEventSource /> > should pass messages to slot 1`] = `
|
||||
{
|
||||
"date": 2019-06-12T10:55:42.459Z,
|
||||
"message": "\\"This is a message.\\"",
|
||||
"id": 1,
|
||||
"message": "This is a message.",
|
||||
"payload": undefined,
|
||||
}
|
||||
`;
|
||||
|
||||
111
assets/composables/eventsource.ts
Normal file
111
assets/composables/eventsource.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { ref, watch, onUnmounted, ComputedRef } from "vue";
|
||||
import debounce from "lodash.debounce";
|
||||
|
||||
import { LogEntry, LogEvent } from "@/types/LogEntry";
|
||||
|
||||
import config from "@/stores/config";
|
||||
import { Container } from "@/types/Container";
|
||||
|
||||
function parseMessage(data: string): LogEntry {
|
||||
const e = JSON.parse(data) as LogEvent;
|
||||
|
||||
const id = e.id;
|
||||
const date = new Date(e.ts * 1000);
|
||||
return { id, date, message: e.m, payload: e.d };
|
||||
}
|
||||
|
||||
export function useEventSource(container: ComputedRef<Container>) {
|
||||
const messages = ref<LogEntry[]>([]);
|
||||
const buffer = ref<LogEntry[]>([]);
|
||||
|
||||
function flushNow() {
|
||||
messages.value.push(...buffer.value);
|
||||
buffer.value = [];
|
||||
}
|
||||
const flushBuffer = debounce(flushNow, 250, { maxWait: 1000 });
|
||||
let es: EventSource | null = null;
|
||||
let lastEventId = "";
|
||||
|
||||
function connect({ clear } = { clear: true }) {
|
||||
es?.close();
|
||||
|
||||
if (clear) {
|
||||
flushBuffer.cancel();
|
||||
messages.value = [];
|
||||
buffer.value = [];
|
||||
lastEventId = "";
|
||||
}
|
||||
|
||||
es = new EventSource(`${config.base}/api/logs/stream?id=${container.value.id}&lastEventId=${lastEventId}`);
|
||||
es.addEventListener("container-stopped", () => {
|
||||
es?.close();
|
||||
es = null;
|
||||
buffer.value.push({
|
||||
event: "container-stopped",
|
||||
message: "Container stopped",
|
||||
date: new Date(),
|
||||
id: new Date().getTime(),
|
||||
});
|
||||
flushBuffer();
|
||||
flushBuffer.flush();
|
||||
});
|
||||
es.addEventListener("error", (e) => console.error("EventSource failed: " + JSON.stringify(e)));
|
||||
es.onmessage = (e) => {
|
||||
lastEventId = e.lastEventId;
|
||||
if (e.data) {
|
||||
buffer.value.push(parseMessage(e.data));
|
||||
flushBuffer();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOlderLogs({ beforeLoading, afterLoading } = { beforeLoading: () => {}, afterLoading: () => {} }) {
|
||||
if (messages.value.length < 300) return;
|
||||
|
||||
beforeLoading();
|
||||
const to = messages.value[0].date;
|
||||
const last = messages.value[299].date;
|
||||
const delta = to.getTime() - last.getTime();
|
||||
const from = new Date(to.getTime() + delta);
|
||||
const logs = await (
|
||||
await fetch(`${config.base}/api/logs?id=${container.value.id}&from=${from.toISOString()}&to=${to.toISOString()}`)
|
||||
).text();
|
||||
if (logs) {
|
||||
const newMessages = logs
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => parseMessage(line));
|
||||
messages.value.unshift(...newMessages);
|
||||
}
|
||||
afterLoading();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => container.value.state,
|
||||
(newValue, oldValue) => {
|
||||
console.log("LogEventSource: container changed", newValue, oldValue);
|
||||
if (newValue == "running" && newValue != oldValue) {
|
||||
buffer.value.push({
|
||||
event: "container-started",
|
||||
message: "Container started",
|
||||
date: new Date(),
|
||||
id: new Date().getTime(),
|
||||
});
|
||||
connect({ clear: false });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
if (es) {
|
||||
es.close();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => container.value.id,
|
||||
() => connect()
|
||||
);
|
||||
|
||||
return { connect, messages, loadOlderLogs };
|
||||
}
|
||||
@@ -3,7 +3,20 @@ import { ref, computed, Ref } from "vue";
|
||||
const searchFilter = ref<string>("");
|
||||
const showSearch = ref(false);
|
||||
|
||||
import type { LogEntry } from "@/types/LogEntry";
|
||||
import { VisibleLogEntry } from "@/types/VisibleLogEntry";
|
||||
|
||||
function matchRecord(record: Record<string, any>, regex: RegExp): boolean {
|
||||
for (const key in record) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && regex.test(value)) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(value) && matchRecord(value, regex)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useSearchFilter() {
|
||||
const regex = computed(() => {
|
||||
@@ -11,11 +24,17 @@ export function useSearchFilter() {
|
||||
return isSmartCase ? new RegExp(searchFilter.value, "i") : new RegExp(searchFilter.value);
|
||||
});
|
||||
|
||||
function filteredMessages(messages: Ref<LogEntry[]>) {
|
||||
function filteredMessages(messages: Ref<VisibleLogEntry[]>) {
|
||||
return computed(() => {
|
||||
if (searchFilter && searchFilter.value) {
|
||||
if (searchFilter.value) {
|
||||
try {
|
||||
return messages.value.filter((d) => d.message.match(regex.value));
|
||||
return messages.value.filter((d) => {
|
||||
if (d.hasPayload()) {
|
||||
return matchRecord(d.payload, regex.value);
|
||||
} else {
|
||||
return regex.value.test(d.message ?? "");
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
console.info(`Ignoring SyntaxError from search.`, e);
|
||||
@@ -29,11 +48,17 @@ export function useSearchFilter() {
|
||||
});
|
||||
}
|
||||
|
||||
function markSearch(log: string) {
|
||||
if (searchFilter && searchFilter.value) {
|
||||
return log.replace(regex.value, `<mark>$&</mark>`);
|
||||
function markSearch(log: string): string;
|
||||
function markSearch(log: string[]): string[];
|
||||
function markSearch(log: string | string[]) {
|
||||
if (!searchFilter.value) {
|
||||
return log;
|
||||
}
|
||||
return log;
|
||||
if (Array.isArray(log)) {
|
||||
return log.map((d) => markSearch(d));
|
||||
}
|
||||
|
||||
return log.toString().replace(regex.value, (match) => `<mark>${match}</mark>`);
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
|
||||
13
assets/composables/visible.ts
Normal file
13
assets/composables/visible.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { LogEntry } from "@/types/LogEntry";
|
||||
import { VisibleLogEntry } from "@/types/VisibleLogEntry";
|
||||
import { computed, ComputedRef, Ref } from "vue";
|
||||
|
||||
export function useVisibleFilter(visibleKeys: ComputedRef<Ref<string[][]>>) {
|
||||
function filteredPayload(messages: Ref<LogEntry[]>) {
|
||||
return computed(() => {
|
||||
return messages.value.map((d) => new VisibleLogEntry(d, visibleKeys.value));
|
||||
});
|
||||
}
|
||||
|
||||
return { filteredPayload };
|
||||
}
|
||||
@@ -17,6 +17,7 @@ $menu-item-hover-color: var(--menu-item-hover-color);
|
||||
|
||||
$text-strong: var(--text-strong-color);
|
||||
$text: var(--text-color);
|
||||
$text-light: var(--text-light-color);
|
||||
|
||||
$panel-heading-background-color: var(--panel-heading-background-color);
|
||||
$panel-heading-color: var(--panel-heading-color);
|
||||
@@ -38,8 +39,7 @@ $light-toolbar-color: rgba($grey-darker, 0.7);
|
||||
@import "@oruga-ui/theme-bulma/dist/scss/components/skeleton.scss";
|
||||
@import "splitpanes/dist/splitpanes.css";
|
||||
|
||||
html,
|
||||
[data-theme="dark"] {
|
||||
@mixin dark {
|
||||
--scheme-main: #{$black};
|
||||
--scheme-main-bis: #{$black-bis};
|
||||
--scheme-main-ter: #{$black-ter};
|
||||
@@ -64,93 +64,57 @@ html,
|
||||
|
||||
--text-strong-color: #{$grey-lightest};
|
||||
--text-color: #{$grey-lighter};
|
||||
--text-light-color: #{$grey};
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
--scheme-main: #{$white};
|
||||
--scheme-main-bis: #{$white-bis};
|
||||
--scheme-main-ter: #{$white-ter};
|
||||
|
||||
--border-color: #{$grey-lighter};
|
||||
--border-hover-color: var(--secondary-color);
|
||||
--logo-color: #{$grey-darker};
|
||||
|
||||
--primary-color: #{$turquoise};
|
||||
--secondary-color: #d8f0ca;
|
||||
|
||||
--body-background-color: #{$white-bis};
|
||||
--action-toolbar-background-color: #{$light-toolbar-color};
|
||||
--body-color: #{$grey-darker};
|
||||
|
||||
--menu-item-color: #{$grey-dark};
|
||||
--menu-item-hover-background-color: #eee8e7;
|
||||
--menu-item-hover-color: #{black-ter};
|
||||
|
||||
--panel-heading-background-color: var(--secondary-color);
|
||||
--panel-heading-color: var(--text-strong-color);
|
||||
|
||||
--text-strong-color: #{$grey-dark};
|
||||
--text-color: #{$grey-darker};
|
||||
--text-light-color: #{$grey};
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
@include dark;
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
@include light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
--scheme-main: #{$black};
|
||||
--scheme-main-bis: #{$black-bis};
|
||||
--scheme-main-ter: #{$black-ter};
|
||||
|
||||
--border-color: #{$grey-darker};
|
||||
--border-hover-color: var(--secondary-color);
|
||||
--logo-color: var(--secondary-color);
|
||||
|
||||
--primary-color: #{$turquoise};
|
||||
--secondary-color: #{$yellow};
|
||||
|
||||
--body-background-color: #{$black-bis};
|
||||
--action-toolbar-background-color: #{$dark-toolbar-color};
|
||||
|
||||
--menu-item-active-background-color: var(--primary-color);
|
||||
--menu-item-color: hsl(0, 6%, 87%);
|
||||
--menu-item-hover-background-color: #{$white-ter};
|
||||
--menu-item-hover-color: #{$black-ter};
|
||||
|
||||
--panel-heading-background-color: var(--secondary-color);
|
||||
--panel-heading-color: var(--scheme-main-bis);
|
||||
|
||||
--text-strong-color: #{$grey-lightest};
|
||||
--text-color: #{$grey-lighter};
|
||||
@include dark;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
html {
|
||||
--scheme-main: #{$white};
|
||||
--scheme-main-bis: #{$white-bis};
|
||||
--scheme-main-ter: #{$white-ter};
|
||||
|
||||
--border-color: #{$grey-lighter};
|
||||
--border-hover-color: var(--secondary-color);
|
||||
--logo-color: #{$grey-darker};
|
||||
|
||||
--primary-color: #{$turquoise};
|
||||
--secondary-color: #d8f0ca;
|
||||
|
||||
--body-background-color: #{$white-bis};
|
||||
--action-toolbar-background-color: #{$light-toolbar-color};
|
||||
--body-color: #{$grey-darker};
|
||||
|
||||
--menu-item-color: #{$grey-dark};
|
||||
--menu-item-hover-background-color: #eee8e7;
|
||||
--menu-item-hover-color: #{black-ter};
|
||||
|
||||
--panel-heading-background-color: var(--secondary-color);
|
||||
--panel-heading-color: var(--text-strong-color);
|
||||
|
||||
--text-strong-color: #{$grey-dark};
|
||||
--text-color: #{$grey-darker};
|
||||
@include light;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--scheme-main: #{$white};
|
||||
--scheme-main-bis: #{$white-bis};
|
||||
--scheme-main-ter: #{$white-ter};
|
||||
|
||||
--border-color: #{$grey-lighter};
|
||||
--border-hover-color: var(--secondary-color);
|
||||
--logo-color: #{$grey-darker};
|
||||
|
||||
--primary-color: #{$turquoise};
|
||||
--secondary-color: #d8f0ca;
|
||||
|
||||
--body-background-color: #{$white-bis};
|
||||
--action-toolbar-background-color: #{$light-toolbar-color};
|
||||
--body-color: #{$grey-darker};
|
||||
|
||||
--menu-item-color: #{$grey-dark};
|
||||
--menu-item-hover-background-color: #eee8e7;
|
||||
--menu-item-hover-color: #{black-ter};
|
||||
|
||||
--panel-heading-background-color: var(--secondary-color);
|
||||
--panel-heading-color: var(--text-strong-color);
|
||||
|
||||
--text-strong-color: #{$grey-dark};
|
||||
--text-color: #{$grey-darker};
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: unset;
|
||||
overflow-y: unset;
|
||||
|
||||
1
assets/types/Container.d.ts
vendored
1
assets/types/Container.d.ts
vendored
@@ -4,6 +4,7 @@ export interface Container {
|
||||
readonly image: string;
|
||||
readonly name: string;
|
||||
readonly status: string;
|
||||
readonly command: string;
|
||||
state: "created" | "running" | "exited" | "dead" | "paused" | "restarting";
|
||||
stat?: ContainerStat;
|
||||
}
|
||||
|
||||
14
assets/types/LogEntry.d.ts
vendored
14
assets/types/LogEntry.d.ts
vendored
@@ -1,7 +1,15 @@
|
||||
export interface LogEntry {
|
||||
date: Date;
|
||||
message: string;
|
||||
key: string;
|
||||
readonly date: Date;
|
||||
readonly message?: string;
|
||||
readonly payload?: Record<string, any>;
|
||||
readonly id: number;
|
||||
event?: string;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export interface LogEvent {
|
||||
readonly m?: string;
|
||||
readonly ts: number;
|
||||
readonly d?: Record<string, any>;
|
||||
readonly id: number;
|
||||
}
|
||||
|
||||
51
assets/types/VisibleLogEntry.ts
Normal file
51
assets/types/VisibleLogEntry.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { computed, ComputedRef, Ref } from "vue";
|
||||
import { LogEntry } from "./LogEntry";
|
||||
import { flattenJSON, getDeep } from "@/utils";
|
||||
|
||||
export class VisibleLogEntry implements LogEntry {
|
||||
private readonly entry: LogEntry;
|
||||
filteredPayload: undefined | ComputedRef<Record<string, any>>;
|
||||
|
||||
constructor(entry: LogEntry, visibleKeys: Ref<string[][]>) {
|
||||
this.entry = entry;
|
||||
this.filteredPayload = undefined;
|
||||
if (this.entry.payload) {
|
||||
const payload = this.entry.payload;
|
||||
this.filteredPayload = computed(() => {
|
||||
if (!visibleKeys.value.length) {
|
||||
return flattenJSON(payload);
|
||||
} else {
|
||||
return visibleKeys.value.reduce((acc, attr) => ({ ...acc, [attr.join(".")]: getDeep(payload, attr) }), {});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public hasPayload(): this is { payload: Record<string, any> } {
|
||||
return this.entry.payload !== undefined;
|
||||
}
|
||||
|
||||
public get unfilteredPayload(): Record<string, any> | undefined {
|
||||
return this.entry.payload;
|
||||
}
|
||||
|
||||
public get payload(): Record<string, any> | undefined {
|
||||
return this.filteredPayload?.value;
|
||||
}
|
||||
|
||||
public get date(): Date {
|
||||
return this.entry.date;
|
||||
}
|
||||
|
||||
public get message(): string | undefined {
|
||||
return this.entry.message;
|
||||
}
|
||||
|
||||
public get id(): number {
|
||||
return this.entry.id;
|
||||
}
|
||||
|
||||
public get event(): string | undefined {
|
||||
return this.entry.event;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import { Container } from "@/types/Container";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import { computed, ComputedRef } from "vue";
|
||||
|
||||
export function formatBytes(bytes: number, decimals = 2) {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
@@ -6,3 +10,38 @@ export function formatBytes(bytes: number, decimals = 2) {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
||||
}
|
||||
|
||||
export function getDeep(obj: Record<string, any>, path: string[]) {
|
||||
return path.reduce((acc, key) => acc?.[key], obj);
|
||||
}
|
||||
|
||||
export function isObject(value: any): value is Record<string, any> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function flattenJSON(obj: Record<string, any>, path: string[] = []) {
|
||||
const result: Record<string, any> = {};
|
||||
Object.keys(obj).forEach((key) => {
|
||||
const value = obj[key];
|
||||
const newPath = path.concat(key);
|
||||
if (isObject(value)) {
|
||||
Object.assign(result, flattenJSON(value, newPath));
|
||||
} else {
|
||||
result[newPath.join(".")] = value;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function arrayEquals(a: string[], b: string[]): boolean {
|
||||
return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((val, index) => val === b[index]);
|
||||
}
|
||||
|
||||
export function persistentVisibleKeys(container: ComputedRef<Container>) {
|
||||
return computed(() => useStorage(stripVersion(container.value.image) + ":" + container.value.command, []));
|
||||
}
|
||||
|
||||
export function stripVersion(label: string) {
|
||||
const [name, _] = label.split(":");
|
||||
return name;
|
||||
}
|
||||
|
||||
@@ -26,3 +26,10 @@ type ContainerEvent struct {
|
||||
ActorID string `json:"actorId"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type LogEvent struct {
|
||||
Message string `json:"m,omitempty"`
|
||||
Timestamp int64 `json:"ts"`
|
||||
Data map[string]interface{} `json:"d,omitempty"`
|
||||
Id uint32 `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
167
package.json
167
package.json
@@ -1,87 +1,88 @@
|
||||
{
|
||||
"name": "dozzle",
|
||||
"version": "3.13.1",
|
||||
"description": "Realtime log viewer for docker containers. ",
|
||||
"homepage": "https://github.com/amir20/dozzle#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/amir20/dozzle/issues"
|
||||
"name": "dozzle",
|
||||
"version": "3.13.1",
|
||||
"description": "Realtime log viewer for docker containers. ",
|
||||
"homepage": "https://github.com/amir20/dozzle#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/amir20/dozzle/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/amir20/dozzle.git"
|
||||
},
|
||||
"license": "ISC",
|
||||
"author": "Amir Raminfar <findamir@gmail.com>",
|
||||
"scripts": {
|
||||
"watch:assets": "vite --open",
|
||||
"watch:server": "LIVE_FS=true DOZZLE_ADDR=:3100 reflex -c .reflex",
|
||||
"dev": "make fake_assets && npm-run-all -p watch:assets watch:server",
|
||||
"build": "vite build",
|
||||
"release": "release-it",
|
||||
"test": "TZ=UTC vitest",
|
||||
"postinstall": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify-json/carbon": "^1.1.7",
|
||||
"@iconify-json/cil": "^1.1.2",
|
||||
"@iconify-json/mdi": "^1.1.30",
|
||||
"@iconify-json/mdi-light": "^1.1.2",
|
||||
"@iconify-json/octicon": "^1.1.16",
|
||||
"@oruga-ui/oruga-next": "^0.5.4",
|
||||
"@oruga-ui/theme-bulma": "^0.2.6",
|
||||
"@vitejs/plugin-vue": "3.0.3",
|
||||
"@vue/compiler-sfc": "^3.2.37",
|
||||
"@vueuse/core": "^9.1.0",
|
||||
"@vueuse/router": "^9.1.0",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"bulma": "^0.9.4",
|
||||
"date-fns": "^2.29.1",
|
||||
"fuzzysort": "^2.0.1",
|
||||
"hotkeys-js": "^3.9.4",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"pinia": "^2.0.18",
|
||||
"sass": "^1.54.4",
|
||||
"semver": "^7.3.7",
|
||||
"splitpanes": "^3.1.1",
|
||||
"typescript": "^4.7.4",
|
||||
"unplugin-auto-import": "^0.11.1",
|
||||
"unplugin-icons": "^0.14.8",
|
||||
"unplugin-vue-components": "^0.22.4",
|
||||
"vite": "3.0.7",
|
||||
"vue": "^3.2.37",
|
||||
"vue-router": "^4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pinia/testing": "^0.0.13",
|
||||
"@types/jest": "^28.1.7",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/node": "^18.7.5",
|
||||
"@types/semver": "^7.3.12",
|
||||
"@vue/test-utils": "^2.0.2",
|
||||
"c8": "^7.12.0",
|
||||
"eventsourcemock": "^2.0.0",
|
||||
"husky": "^8.0.1",
|
||||
"jest-serializer-vue": "^2.0.2",
|
||||
"jsdom": "^20.0.0",
|
||||
"lint-staged": "^13.0.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^2.7.1",
|
||||
"release-it": "^15.3.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"vitest": "^0.22.0",
|
||||
"vue-tsc": "^0.40.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,vue,css}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"release-it": {
|
||||
"github": {
|
||||
"release": false,
|
||||
"releaseNotes": "git log --pretty=format:\"* %s (%h)\" $(git describe --abbrev=0 --tags $(git rev-list --tags --skip=1 --max-count=1))...HEAD~1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/amir20/dozzle.git"
|
||||
},
|
||||
"license": "ISC",
|
||||
"author": "Amir Raminfar <findamir@gmail.com>",
|
||||
"scripts": {
|
||||
"watch:assets": "vite --open",
|
||||
"watch:server": "LIVE_FS=true DOZZLE_ADDR=:3100 reflex -c .reflex",
|
||||
"dev": "make fake_assets && npm-run-all -p watch:assets watch:server",
|
||||
"build": "vite build",
|
||||
"release": "release-it",
|
||||
"test": "TZ=UTC vitest",
|
||||
"postinstall": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@iconify-json/carbon": "^1.1.7",
|
||||
"@iconify-json/cil": "^1.1.2",
|
||||
"@iconify-json/mdi": "^1.1.30",
|
||||
"@iconify-json/mdi-light": "^1.1.2",
|
||||
"@iconify-json/octicon": "^1.1.16",
|
||||
"@oruga-ui/oruga-next": "^0.5.4",
|
||||
"@oruga-ui/theme-bulma": "^0.2.6",
|
||||
"@vitejs/plugin-vue": "3.0.3",
|
||||
"@vue/compiler-sfc": "^3.2.37",
|
||||
"@vueuse/core": "^9.1.0",
|
||||
"@vueuse/router": "^9.1.0",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"bulma": "^0.9.4",
|
||||
"date-fns": "^2.29.1",
|
||||
"fuzzysort": "^2.0.1",
|
||||
"hotkeys-js": "^3.9.4",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"pinia": "^2.0.18",
|
||||
"sass": "^1.54.4",
|
||||
"semver": "^7.3.7",
|
||||
"splitpanes": "^3.1.1",
|
||||
"typescript": "^4.7.4",
|
||||
"unplugin-auto-import": "^0.11.1",
|
||||
"unplugin-icons": "^0.14.8",
|
||||
"unplugin-vue-components": "^0.22.4",
|
||||
"vite": "3.0.7",
|
||||
"vue": "^3.2.37",
|
||||
"vue-router": "^4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pinia/testing": "^0.0.13",
|
||||
"@types/jest": "^28.1.7",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/node": "^18.7.5",
|
||||
"@types/semver": "^7.3.12",
|
||||
"@vue/test-utils": "^2.0.2",
|
||||
"c8": "^7.12.0",
|
||||
"eventsourcemock": "^2.0.0",
|
||||
"husky": "^8.0.1",
|
||||
"jest-serializer-vue": "^2.0.2",
|
||||
"jsdom": "^20.0.0",
|
||||
"lint-staged": "^13.0.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^2.7.1",
|
||||
"release-it": "^15.3.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"vitest": "^0.22.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,vue,css}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"release-it": {
|
||||
"github": {
|
||||
"release": false,
|
||||
"releaseNotes": "git log --pretty=format:\"* %s (%h)\" $(git describe --abbrev=0 --tags $(git rev-list --tags --skip=1 --max-count=1))...HEAD~1"
|
||||
},
|
||||
"npm": {
|
||||
"publish": false
|
||||
}
|
||||
"npm": {
|
||||
"publish": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
49
pnpm-lock.yaml
generated
49
pnpm-lock.yaml
generated
@@ -46,6 +46,7 @@ specifiers:
|
||||
vitest: ^0.22.0
|
||||
vue: ^3.2.37
|
||||
vue-router: ^4.1.3
|
||||
vue-tsc: ^0.40.1
|
||||
|
||||
dependencies:
|
||||
'@iconify-json/carbon': 1.1.7
|
||||
@@ -95,6 +96,7 @@ devDependencies:
|
||||
release-it: 15.3.0
|
||||
ts-node: 10.9.1_7se4izhtmlozvpvgaax5rnuwve
|
||||
vitest: 0.22.0_jsdom@20.0.0+sass@1.54.4
|
||||
vue-tsc: 0.40.1_typescript@4.7.4
|
||||
|
||||
packages:
|
||||
|
||||
@@ -620,6 +622,42 @@ packages:
|
||||
vue: 3.2.37
|
||||
dev: false
|
||||
|
||||
/@volar/code-gen/0.40.1:
|
||||
resolution: {integrity: sha512-mN1jn08wRKLoUj+KThltyWfsiEGt6Um1yT6S7bkruwV76yiLlzIR4WZgWng254byGMozJ00qgkZmBhraD5b48A==}
|
||||
dependencies:
|
||||
'@volar/source-map': 0.40.1
|
||||
dev: true
|
||||
|
||||
/@volar/source-map/0.40.1:
|
||||
resolution: {integrity: sha512-ORYg5W+R4iT2k/k2U4ASkKvDxabIzKtP+lXZ1CcqFIbTF81GOooAv5tJZImf8ifhUV9p8bgGaitFj/VnNzkdYg==}
|
||||
dev: true
|
||||
|
||||
/@volar/typescript-faster/0.40.1:
|
||||
resolution: {integrity: sha512-UiX8OzVRJtpudGfTY2KgB5m78DIA8oVbwI4QN5i4Ot8oURQPOviH7MahikHeeXidbh3iOy/u4vceMb+mfdizpQ==}
|
||||
dependencies:
|
||||
semver: 7.3.7
|
||||
dev: true
|
||||
|
||||
/@volar/vue-language-core/0.40.1:
|
||||
resolution: {integrity: sha512-RBU2nQkj+asKZ/ht3sU3hTau+dGuTjJrQS3nNSw4+vnwUJnN/WogO/MmgKdrvVf3pUdLiucIog1E/Us1C8Y5wg==}
|
||||
dependencies:
|
||||
'@volar/code-gen': 0.40.1
|
||||
'@volar/source-map': 0.40.1
|
||||
'@vue/compiler-core': 3.2.37
|
||||
'@vue/compiler-dom': 3.2.37
|
||||
'@vue/compiler-sfc': 3.2.37
|
||||
'@vue/reactivity': 3.2.37
|
||||
'@vue/shared': 3.2.37
|
||||
dev: true
|
||||
|
||||
/@volar/vue-typescript/0.40.1:
|
||||
resolution: {integrity: sha512-58nW/Xwy7VBkeIPmbyEmi/j1Ta2HxGl/5aFiEEpWxoas7vI1AM+txz8+MhWho4ZMw0w0eCqPtGgugD2rr+/v7w==}
|
||||
dependencies:
|
||||
'@volar/code-gen': 0.40.1
|
||||
'@volar/typescript-faster': 0.40.1
|
||||
'@volar/vue-language-core': 0.40.1
|
||||
dev: true
|
||||
|
||||
/@vue/compiler-core/3.2.37:
|
||||
resolution: {integrity: sha512-81KhEjo7YAOh0vQJoSmAD68wLfYqJvoiD4ulyedzF+OEk/bk6/hx3fTNVfuzugIIaTrOx4PGx6pAiBRe5e9Zmg==}
|
||||
dependencies:
|
||||
@@ -4688,6 +4726,17 @@ packages:
|
||||
vue: 3.2.37
|
||||
dev: false
|
||||
|
||||
/vue-tsc/0.40.1_typescript@4.7.4:
|
||||
resolution: {integrity: sha512-Z+3rlp/6TrtKvLuaFYwBn03zrdinMR6lBb3mWBJtDA+KwlRu+I4eMoqC1qT9D7i/29u0Bw58dH7ErjMpNLN9bQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: '*'
|
||||
dependencies:
|
||||
'@volar/vue-language-core': 0.40.1
|
||||
'@volar/vue-typescript': 0.40.1
|
||||
typescript: 4.7.4
|
||||
dev: true
|
||||
|
||||
/vue/3.2.37:
|
||||
resolution: {integrity: sha512-bOKEZxrm8Eh+fveCqS1/NkG/n6aMidsI6hahas7pa0w/l7jkbssJVsRhVDs07IdDq7h9KHswZOgItnwJAgtVtQ==}
|
||||
dependencies:
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"paths": {
|
||||
"@/*": ["assets/*"]
|
||||
}
|
||||
},
|
||||
"jsx": "preserve",
|
||||
},
|
||||
"include": ["assets/**/*.ts", "assets/**/*.d.ts", "assets/**/*.vue"],
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ Connection: keep-alive
|
||||
Content-Type: text/event-stream
|
||||
X-Accel-Buffering: no
|
||||
|
||||
data: INFO Testing logs...
|
||||
data: {"m":"INFO Testing logs...","ts":0,"id":4256192898}
|
||||
|
||||
event: container-stopped
|
||||
data: end of stream
|
||||
@@ -170,8 +170,8 @@ Connection: keep-alive
|
||||
Content-Type: text/event-stream
|
||||
X-Accel-Buffering: no
|
||||
|
||||
data: 2020-05-13T18:55:37.772853839Z INFO Testing logs...
|
||||
id: 2020-05-13T18:55:37.772853839Z
|
||||
data: {"m":"INFO Testing logs...","ts":1589396137,"id":1469707724}
|
||||
id: 1589396137
|
||||
|
||||
event: container-stopped
|
||||
data: end of stream
|
||||
103
web/logs.go
103
web/logs.go
@@ -4,6 +4,8 @@ import (
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"hash/fnv"
|
||||
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -13,29 +15,12 @@ import (
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/amir20/dozzle/docker"
|
||||
"github.com/dustin/go-humanize"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
|
||||
|
||||
from, _ := time.Parse(time.RFC3339, r.URL.Query().Get("from"))
|
||||
to, _ := time.Parse(time.RFC3339, r.URL.Query().Get("to"))
|
||||
id := r.URL.Query().Get("id")
|
||||
|
||||
reader, err := h.client.ContainerLogsBetweenDates(r.Context(), id, from, to)
|
||||
defer reader.Close()
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
io.Copy(w, reader)
|
||||
}
|
||||
|
||||
func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
container, err := h.client.FindContainer(id)
|
||||
@@ -63,6 +48,68 @@ func (h *handler) downloadLogs(w http.ResponseWriter, r *http.Request) {
|
||||
io.Copy(zw, reader)
|
||||
}
|
||||
|
||||
func logEventIterator(reader *bufio.Reader) func() (docker.LogEvent, error) {
|
||||
return func() (docker.LogEvent, error) {
|
||||
message, readerError := reader.ReadString('\n')
|
||||
h := fnv.New32a()
|
||||
h.Write([]byte(message))
|
||||
logEvent := docker.LogEvent{Id: h.Sum32()}
|
||||
|
||||
if index := strings.IndexAny(message, " "); index != -1 {
|
||||
logId := message[:index]
|
||||
if timestamp, err := time.Parse(time.RFC3339Nano, logId); err == nil {
|
||||
logEvent.Timestamp = timestamp.Unix()
|
||||
message = strings.TrimSuffix(message[index+1:], "\n")
|
||||
if strings.HasPrefix(message, "{") && strings.HasSuffix(message, "}") {
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(message), &data); err != nil {
|
||||
log.Errorf("json unmarshal error while streaming %v", err.Error())
|
||||
}
|
||||
logEvent.Data = data
|
||||
} else {
|
||||
logEvent.Message = message
|
||||
}
|
||||
} else {
|
||||
logEvent.Message = message
|
||||
}
|
||||
} else {
|
||||
logEvent.Message = message
|
||||
}
|
||||
|
||||
return logEvent, readerError
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) fetchLogsBetweenDates(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/ld+json; charset=UTF-8")
|
||||
|
||||
from, _ := time.Parse(time.RFC3339, r.URL.Query().Get("from"))
|
||||
to, _ := time.Parse(time.RFC3339, r.URL.Query().Get("to"))
|
||||
id := r.URL.Query().Get("id")
|
||||
|
||||
reader, err := h.client.ContainerLogsBetweenDates(r.Context(), id, from, to)
|
||||
defer reader.Close()
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
buffered := bufio.NewReader(reader)
|
||||
eventIterator := logEventIterator(buffered)
|
||||
|
||||
for {
|
||||
logEvent, readerError := eventIterator()
|
||||
if readerError != nil {
|
||||
break
|
||||
}
|
||||
if err := json.NewEncoder(w).Encode(logEvent); err != nil {
|
||||
log.Errorf("json encoding error while streaming %v", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.URL.Query().Get("id")
|
||||
if id == "" {
|
||||
@@ -122,15 +169,19 @@ func (h *handler) streamLogs(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
buffered := bufio.NewReader(reader)
|
||||
var readerError error
|
||||
var message string
|
||||
eventIterator := logEventIterator(buffered)
|
||||
|
||||
for {
|
||||
message, readerError = buffered.ReadString('\n')
|
||||
fmt.Fprintf(w, "data: %s\n", strings.TrimRight(message, "\n"))
|
||||
if index := strings.IndexAny(message, " "); index != -1 {
|
||||
id := message[:index]
|
||||
if _, err := time.Parse(time.RFC3339Nano, id); err == nil {
|
||||
fmt.Fprintf(w, "id: %s\n", id)
|
||||
}
|
||||
|
||||
var logEvent docker.LogEvent
|
||||
logEvent, readerError = eventIterator()
|
||||
if buf, err := json.Marshal(logEvent); err != nil {
|
||||
log.Errorf("json encoding error while streaming %v", err.Error())
|
||||
} else {
|
||||
fmt.Fprintf(w, "data: %s\n", buf)
|
||||
}
|
||||
if logEvent.Timestamp > 0 {
|
||||
fmt.Fprintf(w, "id: %d\n", logEvent.Timestamp)
|
||||
}
|
||||
fmt.Fprintf(w, "\n")
|
||||
f.Flush()
|
||||
|
||||
Reference in New Issue
Block a user