From 8de492d16b2a30b308f1a3082b0e41733a087c26 Mon Sep 17 00:00:00 2001 From: Amir Raminfar Date: Tue, 13 Sep 2022 14:28:53 -0700 Subject: [PATCH] Trims top events depending on scroll status (#1860) * Trims top events depending on scroll status * Cleans up models and adds better OOP around logging events * Adds log entries being skipped component * Fixes type errors * Fixes tets * Styles skipping logs --- assets/components.d.ts | 21 ++- .../ComplexLogItem.vue} | 22 +-- .../{ => LogViewer}/ContainerStat.vue | 0 .../{ => LogViewer}/ContainerTitle.vue | 0 .../LogViewer/DockerEventLogItem.vue | 27 ++++ .../{ => LogViewer}/LogContainer.vue | 0 .../{ => LogViewer}/LogEventSource.spec.ts | 3 +- .../{ => LogViewer}/LogEventSource.vue | 5 +- .../components/{ => LogViewer}/LogViewer.vue | 47 ++---- .../{ => LogViewer}/LogViewerWithSource.vue | 4 +- assets/components/LogViewer/SimpleLogItem.vue | 30 ++++ .../LogViewer/SkippedEntriesLogItem.vue | 24 +++ .../__snapshots__/LogEventSource.spec.ts.snap | 150 ++++++++---------- assets/components/ScrollableView.vue | 2 + assets/composables/eventsource.ts | 78 +++++---- assets/composables/search.ts | 8 +- assets/composables/visible.ts | 13 +- assets/models/LogEntry.ts | 117 ++++++++++++++ assets/stores/config.ts | 19 ++- assets/types/LogEntry.d.ts | 16 -- assets/types/VisibleLogEntry.ts | 54 ------- 21 files changed, 388 insertions(+), 252 deletions(-) rename assets/components/{JSONPayload.vue => LogViewer/ComplexLogItem.vue} (69%) rename assets/components/{ => LogViewer}/ContainerStat.vue (100%) rename assets/components/{ => LogViewer}/ContainerTitle.vue (100%) create mode 100644 assets/components/LogViewer/DockerEventLogItem.vue rename assets/components/{ => LogViewer}/LogContainer.vue (100%) rename assets/components/{ => LogViewer}/LogEventSource.spec.ts (98%) rename assets/components/{ => LogViewer}/LogEventSource.vue (89%) rename assets/components/{ => LogViewer}/LogViewer.vue (74%) rename assets/components/{ => LogViewer}/LogViewerWithSource.vue (84%) create mode 100644 assets/components/LogViewer/SimpleLogItem.vue create mode 100644 assets/components/LogViewer/SkippedEntriesLogItem.vue rename assets/components/{ => LogViewer}/__snapshots__/LogEventSource.spec.ts.snap (66%) create mode 100644 assets/models/LogEntry.ts delete mode 100644 assets/types/LogEntry.d.ts delete mode 100644 assets/types/VisibleLogEntry.ts diff --git a/assets/components.d.ts b/assets/components.d.ts index 323db965..1d761f96 100644 --- a/assets/components.d.ts +++ b/assets/components.d.ts @@ -10,18 +10,22 @@ declare module '@vue/runtime-core' { CarbonCaretDown: typeof import('~icons/carbon/caret-down')['default'] CilColumns: typeof import('~icons/cil/columns')['default'] CilFindInPage: typeof import('~icons/cil/find-in-page')['default'] - ContainerStat: typeof import('./components/ContainerStat.vue')['default'] - ContainerTitle: typeof import('./components/ContainerTitle.vue')['default'] + ComplexLogItem: typeof import('./components/LogViewer/ComplexLogItem.vue')['default'] + ComplexPayload: typeof import('./components/LogViewer/ComplexPayload.vue')['default'] + ContainerStat: typeof import('./components/LogViewer/ContainerStat.vue')['default'] + ContainerTitle: typeof import('./components/LogViewer/ContainerTitle.vue')['default'] + copy: typeof import('./components/LogViewer/DockerEventLogItem copy.vue')['default'] + DockerEventLogItem: typeof import('./components/LogViewer/DockerEventLogItem.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'] + JSONPayload: typeof import('./components/LogViewer/JSONPayload.vue')['default'] LogActionsToolbar: typeof import('./components/LogActionsToolbar.vue')['default'] - LogContainer: typeof import('./components/LogContainer.vue')['default'] - LogEventSource: typeof import('./components/LogEventSource.vue')['default'] - LogViewer: typeof import('./components/LogViewer.vue')['default'] - LogViewerWithSource: typeof import('./components/LogViewerWithSource.vue')['default'] + LogContainer: typeof import('./components/LogViewer/LogContainer.vue')['default'] + LogEventSource: typeof import('./components/LogViewer/LogEventSource.vue')['default'] + LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default'] + LogViewerWithSource: typeof import('./components/LogViewer/LogViewerWithSource.vue')['default'] MdiDotsVertical: typeof import('~icons/mdi/dots-vertical')['default'] MdiLightChevronDoubleDown: typeof import('~icons/mdi-light/chevron-double-down')['default'] MdiLightChevronLeft: typeof import('~icons/mdi-light/chevron-left')['default'] @@ -40,5 +44,8 @@ declare module '@vue/runtime-core' { ScrollProgress: typeof import('./components/ScrollProgress.vue')['default'] Search: typeof import('./components/Search.vue')['default'] SideMenu: typeof import('./components/SideMenu.vue')['default'] + SimpleLogItem: typeof import('./components/LogViewer/SimpleLogItem.vue')['default'] + SkippedEntriesLogItem: typeof import('./components/LogViewer/SkippedEntriesLogItem.vue')['default'] + StringPayload: typeof import('./components/LogViewer/StringPayload.vue')['default'] } } diff --git a/assets/components/JSONPayload.vue b/assets/components/LogViewer/ComplexLogItem.vue similarity index 69% rename from assets/components/JSONPayload.vue rename to assets/components/LogViewer/ComplexLogItem.vue index eefdc4dc..6001a35d 100644 --- a/assets/components/JSONPayload.vue +++ b/assets/components/LogViewer/ComplexLogItem.vue @@ -7,27 +7,19 @@ - + diff --git a/assets/components/LogContainer.vue b/assets/components/LogViewer/LogContainer.vue similarity index 100% rename from assets/components/LogContainer.vue rename to assets/components/LogViewer/LogContainer.vue diff --git a/assets/components/LogEventSource.spec.ts b/assets/components/LogViewer/LogEventSource.spec.ts similarity index 98% rename from assets/components/LogEventSource.spec.ts rename to assets/components/LogViewer/LogEventSource.spec.ts index 4eda4420..f9e445c8 100644 --- a/assets/components/LogEventSource.spec.ts +++ b/assets/components/LogViewer/LogEventSource.spec.ts @@ -4,7 +4,7 @@ import { createTestingPinia } from "@pinia/testing"; import EventSource, { sources } from "eventsourcemock"; import LogEventSource from "./LogEventSource.vue"; import LogViewer from "./LogViewer.vue"; -import { settings } from "../composables/settings"; +import { settings } from "../../composables/settings"; import { useSearchFilter } from "@/composables/search"; import { vi, describe, expect, beforeEach, test, beforeAll, afterAll, afterEach } from "vitest"; import { computed, nextTick } from "vue"; @@ -68,6 +68,7 @@ describe("", () => { }, provide: { container: computed(() => ({ id: "abc", image: "test:v123" })), + scrollingPaused: computed(() => false), }, }, slots: { diff --git a/assets/components/LogEventSource.vue b/assets/components/LogViewer/LogEventSource.vue similarity index 89% rename from assets/components/LogEventSource.vue rename to assets/components/LogViewer/LogEventSource.vue index a7bedc5c..b5046dcd 100644 --- a/assets/components/LogEventSource.vue +++ b/assets/components/LogViewer/LogEventSource.vue @@ -7,7 +7,10 @@ import { type Container } from "@/types/Container"; import { type ComputedRef } from "vue"; -const emit = defineEmits(["loading-more"]); +const emit = defineEmits<{ + (e: "loading-more", value: boolean): void; +}>(); + const container = inject("container") as ComputedRef; const { connect, messages, loadOlderLogs } = useLogStream(container); diff --git a/assets/components/LogViewer.vue b/assets/components/LogViewer/LogViewer.vue similarity index 74% rename from assets/components/LogViewer.vue rename to assets/components/LogViewer/LogViewer.vue index a94addf7..2a0afa5e 100644 --- a/assets/components/LogViewer.vue +++ b/assets/components/LogViewer/LogViewer.vue @@ -4,7 +4,6 @@ v-for="(item, index) in filtered" :key="item.id" :data-key="item.id" - :data-event="item.event" :class="{ selected: toRaw(item) === toRaw(lastSelectedItem) }" >
@@ -25,45 +24,35 @@
- - +
+ + diff --git a/assets/components/LogViewer/SkippedEntriesLogItem.vue b/assets/components/LogViewer/SkippedEntriesLogItem.vue new file mode 100644 index 00000000..dd9ca910 --- /dev/null +++ b/assets/components/LogViewer/SkippedEntriesLogItem.vue @@ -0,0 +1,24 @@ + + + + diff --git a/assets/components/__snapshots__/LogEventSource.spec.ts.snap b/assets/components/LogViewer/__snapshots__/LogEventSource.spec.ts.snap similarity index 66% rename from assets/components/__snapshots__/LogEventSource.spec.ts.snap rename to assets/components/LogViewer/__snapshots__/LogEventSource.spec.ts.snap index 7cfe320d..5df93a57 100644 --- a/assets/components/__snapshots__/LogEventSource.spec.ts.snap +++ b/assets/components/LogViewer/__snapshots__/LogEventSource.spec.ts.snap @@ -1,181 +1,169 @@ // Vitest Snapshot v1 exports[` > render html correctly > should render dates with 12 hour style 1`] = ` -"
    -
  • -
    -
    +"" `; exports[` > render html correctly > should render dates with 24 hour style 1`] = ` -"
      -
    • -
      -
      +"" `; exports[` > render html correctly > should render messages 1`] = ` -"
        -
      • -
        -
        +"" `; exports[` > render html correctly > should render messages with color 1`] = ` -"
          -
        • -
          -
          +"" `; exports[` > render html correctly > should render messages with filter 1`] = ` -"
            -
          • -
            -
            +"" `; exports[` > render html correctly > should render messages with html entities 1`] = ` -"
              -
            • -
              -
              +"" `; @@ -188,13 +176,13 @@ exports[` > renders correctly 1`] = `
              -
                " +
                  " `; exports[` > should parse messages 1`] = ` -{ +SimpleLogEntry { + "_message": "This is a message.", "date": 2019-06-12T10:55:42.459Z, "id": 1, - "message": "This is a message.", } `; diff --git a/assets/components/ScrollableView.vue b/assets/components/ScrollableView.vue index 69af4cc1..57703b3e 100644 --- a/assets/components/ScrollableView.vue +++ b/assets/components/ScrollableView.vue @@ -40,6 +40,8 @@ const loading = ref(false); const scrollObserver = ref(); const scrollableContent = ref(); +provide("scrollingPaused", paused); + const mutationObserver = new MutationObserver((e) => { if (!paused.value) { scrollToBottom(); diff --git a/assets/composables/eventsource.ts b/assets/composables/eventsource.ts index f413532c..17540593 100644 --- a/assets/composables/eventsource.ts +++ b/assets/composables/eventsource.ts @@ -1,23 +1,48 @@ -import { onUnmounted, ComputedRef } from "vue"; +import { type ComputedRef, type Ref } from "vue"; import debounce from "lodash.debounce"; -import type { LogEntry, LogEvent } from "@/types/LogEntry"; +import { + type LogEvent, + type JSONObject, + LogEntry, + asLogEntry, + DockerEventLogEntry, + SkippedLogsEntry, +} from "@/models/LogEntry"; import { type Container } from "@/types/Container"; -function parseMessage(data: string): LogEntry { +function parseMessage(data: string): LogEntry { const e = JSON.parse(data) as LogEvent; - - const id = e.id; - const date = new Date(e.ts); - return { id, date, message: e.m }; + return asLogEntry(e); } export function useLogStream(container: ComputedRef) { - const messages = ref([]); - const buffer = ref([]); + let messages = $ref[]>([]); + let buffer = $ref[]>([]); + const scrollingPaused = $ref(inject("scrollingPaused") as Ref); function flushNow() { - messages.value.push(...buffer.value); - buffer.value = []; + if (messages.length > config.maxLogs) { + if (scrollingPaused) { + console.log("Skipping ", buffer.length, " log items"); + if (messages.at(-1) instanceof SkippedLogsEntry) { + const lastEvent = messages.at(-1) as SkippedLogsEntry; + const lastItem = buffer.at(-1) as LogEntry; + lastEvent.addSkippedEntries(buffer.length, lastItem); + } else { + const firstItem = buffer.at(0) as LogEntry; + const lastItem = buffer.at(-1) as LogEntry; + messages.push(new SkippedLogsEntry(new Date(), buffer.length, firstItem, lastItem)); + } + buffer = []; + } else { + messages.push(...buffer); + buffer = []; + messages.splice(0, messages.length - config.maxLogs); + } + } else { + messages.push(...buffer); + buffer = []; + } } const flushBuffer = debounce(flushNow, 250, { maxWait: 1000 }); let es: EventSource | null = null; @@ -28,8 +53,8 @@ export function useLogStream(container: ComputedRef) { if (clear) { flushBuffer.cancel(); - messages.value = []; - buffer.value = []; + messages = []; + buffer = []; lastEventId = ""; } @@ -37,12 +62,8 @@ export function useLogStream(container: ComputedRef) { es.addEventListener("container-stopped", () => { es?.close(); es = null; - buffer.value.push({ - event: "container-stopped", - message: "Container stopped", - date: new Date(), - id: new Date().getTime(), - }); + buffer.push(new DockerEventLogEntry("Container stopped", new Date(), "container-stopped")); + flushBuffer(); flushBuffer.flush(); }); @@ -50,18 +71,18 @@ export function useLogStream(container: ComputedRef) { es.onmessage = (e) => { lastEventId = e.lastEventId; if (e.data) { - buffer.value.push(parseMessage(e.data)); + buffer.push(parseMessage(e.data)); flushBuffer(); } }; } async function loadOlderLogs({ beforeLoading, afterLoading } = { beforeLoading: () => {}, afterLoading: () => {} }) { - if (messages.value.length < 300) return; + if (messages.length < 300) return; beforeLoading(); - const to = messages.value[0].date; - const last = messages.value[299].date; + const to = messages[0].date; + const last = messages[299].date; const delta = to.getTime() - last.getTime(); const from = new Date(to.getTime() + delta); const logs = await ( @@ -72,7 +93,7 @@ export function useLogStream(container: ComputedRef) { .trim() .split("\n") .map((line) => parseMessage(line)); - messages.value.unshift(...newMessages); + messages.unshift(...newMessages); } afterLoading(); } @@ -82,12 +103,7 @@ export function useLogStream(container: ComputedRef) { (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(), - }); + buffer.push(new DockerEventLogEntry("Container started", new Date(), "container-started")); connect({ clear: false }); } } @@ -104,5 +120,5 @@ export function useLogStream(container: ComputedRef) { () => connect() ); - return { connect, messages, loadOlderLogs }; + return $$({ connect, messages, loadOlderLogs }); } diff --git a/assets/composables/search.ts b/assets/composables/search.ts index 86f7df45..ed23e7ae 100644 --- a/assets/composables/search.ts +++ b/assets/composables/search.ts @@ -1,5 +1,5 @@ import { type Ref } from "vue"; -import { type VisibleLogEntry } from "@/types/VisibleLogEntry"; +import { type LogEntry, type JSONObject, SimpleLogEntry, ComplexLogEntry } from "@/models/LogEntry"; const searchFilter = ref(""); const debouncedSearchFilter = useDebounce(searchFilter); @@ -24,14 +24,14 @@ export function useSearchFilter() { return isSmartCase ? new RegExp(debouncedSearchFilter.value, "i") : new RegExp(debouncedSearchFilter.value); }); - function filteredMessages(messages: Ref) { + function filteredMessages(messages: Ref[]>) { return computed(() => { if (debouncedSearchFilter.value) { try { return messages.value.filter((d) => { - if (d.isSimple()) { + if (d instanceof SimpleLogEntry) { return regex.value.test(d.message); - } else if (d.isComplex()) { + } else if (d instanceof ComplexLogEntry) { return matchRecord(d.message, regex.value); } throw new Error("Unknown message type"); diff --git a/assets/composables/visible.ts b/assets/composables/visible.ts index 05a2ca48..647bd7e0 100644 --- a/assets/composables/visible.ts +++ b/assets/composables/visible.ts @@ -1,11 +1,16 @@ -import { type LogEntry } from "@/types/LogEntry"; -import { VisibleLogEntry } from "@/types/VisibleLogEntry"; +import { ComplexLogEntry, type JSONObject, type LogEntry } from "@/models/LogEntry"; import type { ComputedRef, Ref } from "vue"; export function useVisibleFilter(visibleKeys: ComputedRef>) { - function filteredPayload(messages: Ref) { + function filteredPayload(messages: Ref[]>) { return computed(() => { - return messages.value.map((d) => new VisibleLogEntry(d, visibleKeys.value)); + return messages.value.map((d) => { + if (d instanceof ComplexLogEntry) { + return ComplexLogEntry.fromLogEvent(d, visibleKeys.value); + } else { + return d; + } + }); }); } diff --git a/assets/models/LogEntry.ts b/assets/models/LogEntry.ts new file mode 100644 index 00000000..d2c21370 --- /dev/null +++ b/assets/models/LogEntry.ts @@ -0,0 +1,117 @@ +import { Component, ComputedRef, Ref } from "vue"; +import { flattenJSON, getDeep } from "@/utils"; +import ComplexLogItem from "@/components/LogViewer/ComplexLogItem.vue"; +import SimpleLogItem from "@/components/LogViewer/SimpleLogItem.vue"; +import DockerEventLogItem from "@/components/LogViewer/DockerEventLogItem.vue"; +import SkippedEntriesLogItem from "@/components/LogViewer/SkippedEntriesLogItem.vue"; + +export interface HasComponent { + getComponent(): Component; +} + +export type JSONValue = string | number | boolean | JSONObject | Array; +export type JSONObject = { [x: string]: JSONValue }; + +export interface LogEvent { + readonly m: string | JSONObject; + readonly ts: number; + readonly id: number; +} + +export abstract class LogEntry implements HasComponent { + protected readonly _message: T; + constructor(message: T, public readonly id: number, public readonly date: Date) { + this._message = message; + } + + public get message(): T { + return this._message; + } + + abstract getComponent(): Component; +} + +export class SimpleLogEntry extends LogEntry { + getComponent(): Component { + return SimpleLogItem; + } +} + +export class ComplexLogEntry extends LogEntry { + private readonly filteredMessage: ComputedRef; + + constructor(message: JSONObject, id: number, date: Date, visibleKeys?: Ref) { + super(message, id, date); + if (visibleKeys) { + this.filteredMessage = computed(() => { + if (!visibleKeys.value.length) { + return flattenJSON(message); + } else { + return visibleKeys.value.reduce((acc, attr) => ({ ...acc, [attr.join(".")]: getDeep(message, attr) }), {}); + } + }); + } else { + this.filteredMessage = computed(() => flattenJSON(message)); + } + } + getComponent(): Component { + return ComplexLogItem; + } + + public get message(): JSONObject { + return this.filteredMessage.value; + } + + public get unfilteredMessage(): JSONObject { + return this._message; + } + + static fromLogEvent(event: ComplexLogEntry, visibleKeys: Ref): ComplexLogEntry { + return new ComplexLogEntry(event._message, event.id, event.date, visibleKeys); + } +} + +export class DockerEventLogEntry extends LogEntry { + constructor(message: string, date: Date, public readonly event: string) { + super(message, date.getTime(), date); + } + getComponent(): Component { + return DockerEventLogItem; + } +} + +export class SkippedLogsEntry extends LogEntry { + private totalSkipped = 0; + private lastSkipped: LogEntry; + + constructor( + date: Date, + totalSkipped: number, + public readonly firstSkipped: LogEntry, + lastSkipped: LogEntry + ) { + super("", date.getTime(), date); + this.totalSkipped = totalSkipped; + this.lastSkipped = lastSkipped; + } + getComponent(): Component { + return SkippedEntriesLogItem; + } + + public get message(): string { + return `Skipped ${this.totalSkipped} entries`; + } + + public addSkippedEntries(totalSkipped: number, lastItem: LogEntry) { + this.totalSkipped += totalSkipped; + this.lastSkipped = lastItem; + } +} + +export function asLogEntry(event: LogEvent): LogEntry { + if (typeof event.m === "string") { + return new SimpleLogEntry(event.m, event.id, new Date(event.ts)); + } else { + return new ComplexLogEntry(event.m, event.id, new Date(event.ts)); + } +} diff --git a/assets/stores/config.ts b/assets/stores/config.ts index 849f73cd..5331b185 100644 --- a/assets/stores/config.ts +++ b/assets/stores/config.ts @@ -1,6 +1,20 @@ const text = document.querySelector("script#config__json")?.textContent || "{}"; -const config = JSON.parse(text); +interface Config { + version: string; + base: string; + authorizationNeeded: boolean | "false" | "true"; + secured: boolean | "false" | "true"; + maxLogs: number; +} + +const pageConfig = JSON.parse(text); + +const config: Config = { + maxLogs: 600, + ...pageConfig, +}; + if (config.version == "{{ .Version }}") { config.version = "master"; config.base = ""; @@ -11,4 +25,5 @@ if (config.version == "{{ .Version }}") { config.authorizationNeeded = config.authorizationNeeded === "true"; config.secured = config.secured === "true"; } -export default config; + +export default config as Config; diff --git a/assets/types/LogEntry.d.ts b/assets/types/LogEntry.d.ts deleted file mode 100644 index a1748342..00000000 --- a/assets/types/LogEntry.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface LogEntry { - readonly date: Date; - readonly message: string | JSONObject; - readonly id: number; - event?: string; - selected?: boolean; -} - -export interface LogEvent { - readonly m: string | JSONObject; - readonly ts: number; - readonly id: number; -} - -export type JSONValue = string | number | boolean | JSONObject | Array; -export type JSONObject = { [x: string]: JSONValue }; diff --git a/assets/types/VisibleLogEntry.ts b/assets/types/VisibleLogEntry.ts deleted file mode 100644 index b293669b..00000000 --- a/assets/types/VisibleLogEntry.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { computed, ComputedRef, Ref } from "vue"; -import { flattenJSON, getDeep } from "@/utils"; -import type { JSONObject, LogEntry } from "./LogEntry"; - -export class VisibleLogEntry implements LogEntry { - private readonly entry: LogEntry; - filteredMessage: undefined | ComputedRef>; - - constructor(entry: LogEntry, visibleKeys: Ref) { - this.entry = entry; - this.filteredMessage = undefined; - if (this.isComplex()) { - const message = this.message; - this.filteredMessage = computed(() => { - if (!visibleKeys.value.length) { - return flattenJSON(message); - } else { - return visibleKeys.value.reduce((acc, attr) => ({ ...acc, [attr.join(".")]: getDeep(message, attr) }), {}); - } - }); - } - } - - public isComplex(): this is { message: JSONObject } { - return typeof this.entry.message === "object"; - } - - public isSimple(): this is { message: string } { - return !this.isComplex(); - } - - public get unfilteredPayload(): JSONObject { - if (typeof this.entry.message === "string") { - throw new Error("Cannot get unfiltered payload of a simple message"); - } - return this.entry.message; - } - - public get date(): Date { - return this.entry.date; - } - - public get message(): string | JSONObject { - return this.filteredMessage?.value ?? this.entry.message; - } - - public get id(): number { - return this.entry.id; - } - - public get event(): string | undefined { - return this.entry.event; - } -}