diff --git a/src/pages/settings/ui/settings-page-content/settings-page-content.vue b/src/pages/settings/ui/settings-page-content/settings-page-content.vue index b387cdc7..730d8e37 100644 --- a/src/pages/settings/ui/settings-page-content/settings-page-content.vue +++ b/src/pages/settings/ui/settings-page-content/settings-page-content.vue @@ -3,15 +3,28 @@ import { useTitle } from '@vueuse/core' import { storeToRefs } from 'pinia' import { computed } from 'vue' import { THEME_MODES, useSettingsStore } from '@/shared/stores' -import { BadgeNumber, IconSvg } from '@/shared/ui' +import { BadgeNumber, IconSvg, SelectControl } from '@/shared/ui' const settingsStore = useSettingsStore() -const { changeTheme, changeNavbar, changeEventCountsVisibility, changeActiveCodeEditor } = - settingsStore -const { themeType, isFixedHeader, isVisibleEventCounts, codeEditor } = storeToRefs(settingsStore) +const { + changeTheme, + changeNavbar, + changeEventCountsVisibility, + setAutoDeleteEventsTime, + changeActiveCodeEditor +} = settingsStore +const { themeType, isFixedHeader, isVisibleEventCounts, autoDeleteEventsTime, codeEditor } = + storeToRefs(settingsStore) const isDarkMode = computed(() => themeType.value === THEME_MODES.DARK) +const deleteEventsAfter = computed({ + get: () => (autoDeleteEventsTime.value === 'none' ? 'none' : String(autoDeleteEventsTime.value)), + set: (val) => { + setAutoDeleteEventsTime(val) + } +}) + // TODO: add throttle const changeCodeEditor = (event: Event) => { const editor = (event.target as HTMLInputElement).value @@ -114,6 +127,21 @@ useTitle('Settings | Buggregator') +
+ Delete Events After: +
+ + +
Code Editor Open Link:
diff --git a/src/shared/lib/io/use-events-requests.ts b/src/shared/lib/io/use-events-requests.ts index fd8f1b26..bac4810d 100644 --- a/src/shared/lib/io/use-events-requests.ts +++ b/src/shared/lib/io/use-events-requests.ts @@ -1,10 +1,16 @@ import {storeToRefs} from "pinia"; import {useEventsStore, useProfileStore} from "../../stores"; -import type { EventId, EventType, ServerEvent } from '../../types'; +import type { EventId, EventType, EventTypeCount, EventsPreviewMeta, ServerEvent } from '../../types'; import { REST_API_URL } from "./constants"; type TUseEventsRequests = () => { getAll: () => Promise[]>, + getPreviewPageByType: ( + type: EventType, + limit: number, + cursor?: string | null + ) => Promise<{ data: ServerEvent[]; meta: EventsPreviewMeta | null }>, + getTypeCounts: () => Promise, getSingle: (id: EventId) => Promise | null>, deleteAll: () => Promise, deleteList: (uuids: EventId[]) => Promise, @@ -22,6 +28,25 @@ export const useEventsRequests: TUseEventsRequests = () => { const headers = {"X-Auth-Token": token.value } const getEventRestUrl = (param: string): string => `${REST_API_URL}/api/event/${param}${project.value ? `?project=${project.value}` : ''}` const getEventsPreviewRestUrl = (): string => `${REST_API_URL}/api/events/preview${project.value ? `?project=${project.value}` : ''}` + const getEventsPreviewByTypeRestUrl = ( + type: EventType, + limit: number, + cursor?: string | null + ): string => { + const params = new URLSearchParams({ + type, + limit: String(limit), + ...(project.value ? { project: project.value } : {}), + }) + + if (cursor) { + params.set('cursor', cursor) + } + + return `${REST_API_URL}/api/events/preview?${params.toString()}` + } + const getEventsTypeCountsRestUrl = (): string => + `${REST_API_URL}/api/events/type-counts${project.value ? `?project=${project.value}` : ''}` const getAll = () => fetch(getEventsPreviewRestUrl(), { headers }) .then((response) => response.json()) @@ -41,6 +66,45 @@ export const useEventsRequests: TUseEventsRequests = () => { }) .then((events: ServerEvent[]) => events) + const getPreviewPageByType = (type: EventType, limit: number, cursor?: string | null) => + fetch(getEventsPreviewByTypeRestUrl(type, limit, cursor), { headers }) + .then((response) => response.json()) + .then((response) => { + if (response?.data) { + return { + data: response.data as ServerEvent[], + meta: (response.meta ?? null) as EventsPreviewMeta | null, + } + } + + if (response?.code === 403) { + console.error('Forbidden') + return { data: [], meta: null } + } + + console.error('Fetch Error') + + return { data: [], meta: null } + }) + + const getTypeCounts = () => fetch(getEventsTypeCountsRestUrl(), { headers }) + .then((response) => response.json()) + .then((response) => { + if (response?.data) { + return response.data as EventTypeCount[] + } + + if (response?.code === 403) { + console.error('Forbidden') + return []; + } + + console.error('Fetch Error') + + return []; + }) + .then((counts: EventTypeCount[]) => counts) + const getSingle = (id: EventId) => fetch(getEventRestUrl(id), {headers}) .then((response) => response.json()) .then((response) => { @@ -91,6 +155,8 @@ export const useEventsRequests: TUseEventsRequests = () => { return { getAll, + getPreviewPageByType, + getTypeCounts, getSingle, deleteAll, deleteList, diff --git a/src/shared/lib/use-api-transport/use-api-transport.ts b/src/shared/lib/use-api-transport/use-api-transport.ts index 1d248682..bbab00df 100644 --- a/src/shared/lib/use-api-transport/use-api-transport.ts +++ b/src/shared/lib/use-api-transport/use-api-transport.ts @@ -26,6 +26,8 @@ export const useApiTransport = () => { const connectionStore = useConnectionStore() const { getAll, + getPreviewPageByType, + getTypeCounts, getSingle, deleteAll, deleteList, @@ -137,6 +139,8 @@ export const useApiTransport = () => { return { getEventsAll: getAll, + getEventsPreviewByTypePage: getPreviewPageByType, + getEventsTypeCounts: getTypeCounts, getEvent: getSingle, deleteEvent, deleteEventsAll, diff --git a/src/shared/lib/use-events/auto-delete-events.ts b/src/shared/lib/use-events/auto-delete-events.ts new file mode 100644 index 00000000..63b9b96d --- /dev/null +++ b/src/shared/lib/use-events/auto-delete-events.ts @@ -0,0 +1,116 @@ +import { storeToRefs } from "pinia"; +import { watch } from "vue"; +import { useEventsStore, useSettingsStore } from "../../stores"; +import type { EventId } from "../../types"; +import { useEventsApi } from "./use-events-api"; + +type AutoDeleteTime = number | "none"; + +const autoDeleteTimers = new Map>(); +let autoDeleteInitialized = false; + +const toAutoDeleteMs = (val: AutoDeleteTime): number | null => { + if (val === "none") return null; + return val * 60 * 1000; +}; + +const clearAutoDeleteTimer = (eventId: EventId) => { + const timer = autoDeleteTimers.get(eventId); + if (!timer) return; + globalThis.clearTimeout(timer); + autoDeleteTimers.delete(eventId); +}; + +const clearAllAutoDeleteTimers = () => { + autoDeleteTimers.forEach((timer) => globalThis.clearTimeout(timer)); + autoDeleteTimers.clear(); +}; + +export const ensureAutoDeleteWatcher = () => { + if (autoDeleteInitialized) return; + autoDeleteInitialized = true; + + const eventsStore = useEventsStore(); + const settingsStore = useSettingsStore(); + const eventsApi = useEventsApi(); + + const { events, lockedIds } = storeToRefs(eventsStore); + const { autoDeleteEventsTime } = storeToRefs(settingsStore); + + const schedule = (eventId: EventId) => { + const ms = toAutoDeleteMs(autoDeleteEventsTime.value); + if (ms === null || autoDeleteTimers.has(eventId)) return; + + const timer = globalThis.setTimeout(() => { + autoDeleteTimers.delete(eventId); + + const locked = lockedIds.value ?? []; + if (locked.includes(eventId)) return; + + void eventsApi.removeById(eventId); + }, ms); + + autoDeleteTimers.set(eventId, timer); + }; + + const rescheduleAll = () => { + clearAllAutoDeleteTimers(); + + const ms = toAutoDeleteMs(autoDeleteEventsTime.value); + if (ms === null) return; + + events.value.forEach(({ uuid }) => { + schedule(uuid); + }); + }; + + const syncTimers = (currentIds: Set, prevIds: Set) => { + prevIds.forEach((id) => { + if (!currentIds.has(id)) { + clearAutoDeleteTimer(id); + } + }); + + if (toAutoDeleteMs(autoDeleteEventsTime.value) === null) return; + + currentIds.forEach((id) => { + if (!prevIds.has(id)) { + schedule(id); + } + }); + }; + + watch( + autoDeleteEventsTime, + () => { + rescheduleAll(); + }, + { immediate: true }, + ); + + watch( + () => events.value.map(({ uuid }) => uuid), + (current, prev) => { + const currentIds = new Set(current); + const prevIds = new Set(prev ?? []); + + syncTimers(currentIds, prevIds); + }, + ); + + watch( + () => lockedIds.value.slice(), + (current, prev) => { + const currentIds = new Set(current); + const prevIds = new Set(prev ?? []); + + const eventsIds = new Set(events.value.map(({ uuid }) => uuid)); + const unlockedIds = new Set( + [...prevIds].filter((id) => !currentIds.has(id) && eventsIds.has(id)), + ); + + syncTimers(currentIds, prevIds); + syncTimers(unlockedIds, new Set()); + }, + ); +}; diff --git a/src/shared/lib/use-events/use-events-api.ts b/src/shared/lib/use-events/use-events-api.ts index d99bd39b..caf36c62 100644 --- a/src/shared/lib/use-events/use-events-api.ts +++ b/src/shared/lib/use-events/use-events-api.ts @@ -1,7 +1,7 @@ import { storeToRefs } from "pinia"; import type { Ref } from 'vue'; import { useEventsStore } from "../../stores"; -import type { EventId, EventType, ServerEvent } from '../../types'; +import { EventTypes, type EventId, type EventType, type EventsPreviewMeta, type ServerEvent } from '../../types'; import { useApiTransport } from '../use-api-transport' @@ -9,6 +9,10 @@ export type TUseEventsApi = { items: Ref[]> getItem: (id: EventId) => Promise | null> getAll: () => void + loadMoreByType: ( + type: EventType, + cursor?: string | null + ) => Promise<{ meta: EventsPreviewMeta | null; data: ServerEvent[] }> removeAll: () => void removeByType: (type: EventType) => void removeById: (id: EventId) => void @@ -26,10 +30,13 @@ export const useEventsApi = (): TUseEventsApi => { deleteEventsAll, deleteEventsList, deleteEventsByType, - getEventsAll, + getEventsPreviewByTypePage, + getEventsTypeCounts, getEvent, } = useApiTransport(); + const LOADING_PAGE_SIZE = 25; + const removeList = async (uuids: EventId[]) => { const res = await deleteEventsList(uuids) @@ -79,22 +86,63 @@ export const useEventsApi = (): TUseEventsApi => { } const getAll = () => { - getEventsAll().then((eventsList: ServerEvent[]) => { - if (eventsList.length) { - eventsStore.initializeEvents(eventsList); - } else { - // NOTE: clear cached events hardly - eventsStore.removeAll(); - } - }).catch((e) => { - console.error(e); + eventsStore.initializeEvents([]); + eventsStore.resetPreviewPagination(); + + getEventsTypeCounts() + .then((typeCounts) => { + eventsStore.setEventCounts(typeCounts); + }) + .catch((e) => { + console.error(e); + eventsStore.resetEventCounts(); + }); + + Object.values(EventTypes).forEach((eventType) => { + getEventsPreviewByTypePage(eventType, LOADING_PAGE_SIZE) + .then(({ data, meta }) => { + if (data.length) { + eventsStore.mergeEvents(data, { updateCounts: false }); + } + eventsStore.setPreviewPagination(eventType, meta); + }) + .catch((e) => { + console.error(e); + }) }) } + const loadMoreByType = async (eventType: EventType, cursor?: string | null) => { + const pagination = eventsStore.previewPagination[eventType as EventTypes]; + if (!pagination?.hasMore) { + return { meta: null, data: [] }; + } + + const requestCursor = cursor ?? pagination.cursor; + const { data, meta } = await getEventsPreviewByTypePage( + eventType, + LOADING_PAGE_SIZE, + requestCursor, + ); + + if (data.length) { + eventsStore.mergeEvents(data, { updateCounts: false }); + + if (eventsStore.cachedIds[eventType]?.length) { + eventsStore.appendCachedIds(eventType, data.map(({ uuid }) => uuid)); + } + } + + eventsStore.setPreviewPagination(eventType, meta); + + return { meta, data }; + } + return { items: events as unknown as Ref[]>, getItem: getEvent, getAll, + loadMoreByType, removeAll, removeByType, removeById, diff --git a/src/shared/lib/use-events/use-events.ts b/src/shared/lib/use-events/use-events.ts index 040ccec8..2457ac96 100644 --- a/src/shared/lib/use-events/use-events.ts +++ b/src/shared/lib/use-events/use-events.ts @@ -10,7 +10,6 @@ import { useApiTransport } from "../use-api-transport"; import { normalizeUnknownEvent } from "./normalize-unknown-event"; import { type TUseEventsApi, useEventsApi } from "./use-events-api"; - type TUseEvents = () => { normalizeUnknownEvent: (event: ServerEvent) => NormalizedEvent events: TUseEventsApi diff --git a/src/shared/stores/events/events-store.ts b/src/shared/stores/events/events-store.ts index 82861aed..90daf079 100644 --- a/src/shared/stores/events/events-store.ts +++ b/src/shared/stores/events/events-store.ts @@ -1,5 +1,6 @@ import { defineStore } from "pinia"; import { PAGE_TYPES} from "../../constants"; +import { ensureAutoDeleteWatcher } from "../../lib/use-events/auto-delete-events"; import {useSettings} from "../../lib/use-settings"; import { type EventId, @@ -7,6 +8,7 @@ import { type ServerEvent, type PageEventTypes, type TProjects, + type EventsPreviewMeta, EventTypes } from '../../types'; import {useSettingsStore} from "../settings"; @@ -20,6 +22,7 @@ import { import type {TEventsCachedIdsMap} from "./types"; const MAX_EVENTS_COUNT = 500; +type TPreviewPaginationMap = Record; const initialCachedIds: TEventsCachedIdsMap = { [PAGE_TYPES.Sentry]: [] as EventId[], @@ -33,31 +36,47 @@ const initialCachedIds: TEventsCachedIdsMap = { [PAGE_TYPES.ALL_EVENT_TYPES]: [] as EventId[], }; +const initialEventCounts: Record = { + [EventTypes.VarDump]: 0, + [EventTypes.Smtp]: 0, + [EventTypes.Sentry]: 0, + [EventTypes.Profiler]: 0, + [EventTypes.Monolog]: 0, + [EventTypes.Inspector]: 0, + [EventTypes.HttpDump]: 0, + [EventTypes.RayDump]: 0, +} + +const createInitialPreviewPagination = (): TPreviewPaginationMap => ({ + [EventTypes.VarDump]: { cursor: null, hasMore: false }, + [EventTypes.Smtp]: { cursor: null, hasMore: false }, + [EventTypes.Sentry]: { cursor: null, hasMore: false }, + [EventTypes.Profiler]: { cursor: null, hasMore: false }, + [EventTypes.Monolog]: { cursor: null, hasMore: false }, + [EventTypes.Inspector]: { cursor: null, hasMore: false }, + [EventTypes.HttpDump]: { cursor: null, hasMore: false }, + [EventTypes.RayDump]: { cursor: null, hasMore: false }, +}); + export const useEventsStore = defineStore("eventsStore", { state: () => ({ events: [] as ServerEvent[], cachedIds: getStoredCachedIds() || initialCachedIds, lockedIds: getStoredLockedIds() || [], + eventCounts: { ...initialEventCounts }, + previewPagination: createInitialPreviewPagination(), projects: { available: [] as TProjects['data'], activeKey: undefined as string | undefined, } }), getters: { - eventsCounts: ({events}) => (eventType: EventTypes | undefined): number => { - // TODO: need to use common mapping with changed ids - const counts = { - [EventTypes.VarDump]: events.filter(({type}) => type === EventTypes.VarDump).length, - [EventTypes.Smtp]: events.filter(({type}) => type === EventTypes.Smtp).length, - [EventTypes.Sentry]: events.filter(({type}) => type === EventTypes.Sentry).length, - [EventTypes.Profiler]: events.filter(({type}) => type === EventTypes.Profiler).length, - [EventTypes.Monolog]: events.filter(({type}) => type === EventTypes.Monolog).length, - [EventTypes.Inspector]: events.filter(({type}) => type === EventTypes.Inspector).length, - [EventTypes.HttpDump]: events.filter(({type}) => type === EventTypes.HttpDump).length, - [EventTypes.RayDump]: events.filter(({type}) => type === EventTypes.RayDump).length + eventsCounts: ({ eventCounts }) => (eventType: EventTypes | undefined): number => { + if (eventType) { + return eventCounts[eventType] ?? 0; } - return eventType && counts[eventType] != null ? counts[eventType] : events.length; + return Object.values(eventCounts).reduce((total, count) => total + count, 0); }, cachedIdsTypesList({ cachedIds }) { return Object.entries(cachedIds).filter(([_, value]) => value.length > 0).map(([key]) => key as PageEventTypes) @@ -79,6 +98,7 @@ export const useEventsStore = defineStore("eventsStore", { async initialize (): Promise { const {api: { getProjects }} = useSettings(); this.initActiveProjectKey(); + ensureAutoDeleteWatcher(); try { const { data } = await getProjects(); @@ -100,8 +120,10 @@ export const useEventsStore = defineStore("eventsStore", { this.syncCachedWithActive(events.map(({ uuid }) => uuid)); this.initActiveProjectKey(); }, - addList(events: ServerEvent[]): void { + addList(events: ServerEvent[], options?: { updateCounts?: boolean }): void { const { availableEvents } = useSettingsStore(); + const shouldUpdateCounts = options?.updateCounts !== false; + events .filter((el) => availableEvents.includes(el.type as EventType)) .forEach((event) => { @@ -109,6 +131,10 @@ export const useEventsStore = defineStore("eventsStore", { if (!isExistedEvent) { this.events.unshift(event) + if (shouldUpdateCounts) { + this.incrementEventCount(event.type as EventTypes) + } + if (this.events.length > MAX_EVENTS_COUNT) { this.events.pop() } @@ -123,27 +149,60 @@ export const useEventsStore = defineStore("eventsStore", { } }); }, + mergeEvents(events: ServerEvent[], options?: { updateCounts?: boolean }): void { + const { availableEvents } = useSettingsStore(); + const shouldUpdateCounts = options?.updateCounts !== false; + const filteredEvents = events.filter((el) => availableEvents.includes(el.type as EventType)); + + if (!filteredEvents.length) { + return; + } + + const eventsById = new Map(this.events.map((event) => [event.uuid, event])); + + filteredEvents.forEach((event) => { + const isExistedEvent = eventsById.has(event.uuid); + eventsById.set(event.uuid, event); + + if (shouldUpdateCounts && !isExistedEvent) { + this.incrementEventCount(event.type as EventTypes) + } + }); + + const mergedEvents = Array.from(eventsById.values()) + .sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); + + this.events = mergedEvents.slice(0, MAX_EVENTS_COUNT); + }, removeAll() { if (this.lockedIds.length) { this.events = this.events.filter(({ uuid }) => this.lockedIds.includes(uuid)); - return + this.resetEventCounts(); + return; } this.events.length = 0; this.removeCachedAll(); + this.resetEventCounts(); }, removeByIds(eventUuids: EventId[]) { + const removedEvents = this.events.filter(({ uuid }) => + eventUuids.includes(uuid) && !this.lockedIds.includes(uuid) + ); + if (this.lockedIds.length) { this.events = this.events.filter(({ uuid }) => !eventUuids.includes(uuid) || this.lockedIds.includes(uuid)); - return + removedEvents.forEach((event) => this.decrementEventCount(event.type as EventTypes)); + return; } this.events = this.events.filter(({ uuid }) => !eventUuids.includes(uuid)); this.removeCachedByIds(eventUuids); + removedEvents.forEach((event) => this.decrementEventCount(event.type as EventTypes)); }, removeById(eventUuid: EventId) { this.removeByIds([eventUuid]); @@ -154,10 +213,12 @@ export const useEventsStore = defineStore("eventsStore", { if (this.lockedIds.length) { this.events = this.events.filter(({ type, uuid }) => type !== eventType || this.lockedIds.includes(uuid)); - return + this.resetEventCountByType(eventType as EventTypes); + return; } this.events = this.events.filter(({ type }) => type !== eventType); + this.resetEventCountByType(eventType as EventTypes); }, // cached ids addCachedByType(cachedType: PageEventTypes) { @@ -171,6 +232,17 @@ export const useEventsStore = defineStore("eventsStore", { setStoredCachedIds(this.cachedIds); }, + appendCachedIds(cachedType: PageEventTypes, uuids: EventId[]) { + if (!uuids.length) { + return; + } + + const cached = new Set(this.cachedIds[cachedType]); + uuids.forEach((uuid) => cached.add(uuid)); + + this.cachedIds[cachedType] = Array.from(cached); + setStoredCachedIds(this.cachedIds); + }, removeCachedByType(type: PageEventTypes) { this.cachedIds[type].length = 0; setStoredCachedIds(this.cachedIds); @@ -206,6 +278,20 @@ export const useEventsStore = defineStore("eventsStore", { setStoredCachedIds(this.cachedIds); }, + setPreviewPagination(type: EventTypes, meta: EventsPreviewMeta | null) { + if (!meta) { + this.previewPagination[type] = { cursor: null, hasMore: false }; + return; + } + + this.previewPagination[type] = { + cursor: meta.next_cursor ?? null, + hasMore: meta.has_more, + }; + }, + resetPreviewPagination() { + this.previewPagination = createInitialPreviewPagination(); + }, // locked ids removeLockedIds(eventUuid: EventId) { this.lockedIds = this.lockedIds.filter((id) => id !== eventUuid); @@ -217,6 +303,35 @@ export const useEventsStore = defineStore("eventsStore", { setStoredLockedIds(this.lockedIds); }, + setEventCounts(typeCounts: { type: string; count: number }[]) { + const nextCounts = { ...initialEventCounts }; + + typeCounts.forEach(({ type, count }) => { + if (type in nextCounts) { + nextCounts[type as EventTypes] = count; + } + }); + + this.eventCounts = nextCounts; + }, + incrementEventCount(type: EventTypes, amount = 1) { + if (type in this.eventCounts) { + this.eventCounts[type] = Math.max(0, this.eventCounts[type] + amount); + } + }, + decrementEventCount(type: EventTypes, amount = 1) { + if (type in this.eventCounts) { + this.eventCounts[type] = Math.max(0, this.eventCounts[type] - amount); + } + }, + resetEventCounts() { + this.eventCounts = { ...initialEventCounts }; + }, + resetEventCountByType(type: EventTypes) { + if (type in this.eventCounts) { + this.eventCounts[type] = 0; + } + }, // projects initActiveProjectKey() { this.projects.activeKey = getStoredProject() || this.activeProjectKey; diff --git a/src/shared/stores/settings/local-storage-actions.ts b/src/shared/stores/settings/local-storage-actions.ts index cac56fd9..4f6d650d 100644 --- a/src/shared/stores/settings/local-storage-actions.ts +++ b/src/shared/stores/settings/local-storage-actions.ts @@ -70,6 +70,30 @@ export const setStoredEventsCountVisibility = (state: boolean) => { window?.localStorage?.setItem(LocalStorageKeys.EventCounts, String(state)); } +export const getStoredAutoDeleteEventsTime = (): number | 'none' => { + const raw = window?.localStorage?.getItem( + LocalStorageKeys.AutoDeleteEventsTime, + ); + if (raw === null) { + return 'none'; + } + const value = Number(raw); + return Number.isFinite(value) && value > 0 ? value : 'none'; +}; + +export const setStoredAutoDeleteEventsTime = (minutes: number | 'none'): void => { + if (minutes === 'none') { + window?.localStorage?.removeItem( + LocalStorageKeys.AutoDeleteEventsTime, + ); + return; + } + + window?.localStorage?.setItem( + LocalStorageKeys.AutoDeleteEventsTime, + String(minutes), + ); +}; export const getStoredPrimaryCodeEditor = (): string => { const storedCodeEditor = window?.localStorage?.getItem(LocalStorageKeys.CodeEditor); diff --git a/src/shared/stores/settings/settings-store.ts b/src/shared/stores/settings/settings-store.ts index 51cc2180..7312f7b3 100644 --- a/src/shared/stores/settings/settings-store.ts +++ b/src/shared/stores/settings/settings-store.ts @@ -7,6 +7,8 @@ import { getStoredFixedHeader, getStoredActiveTheme, setStoredEventsCountVisibility, + getStoredAutoDeleteEventsTime, + setStoredAutoDeleteEventsTime, setStoredFixedHeader, setStoredActiveTheme, getStoredPrimaryCodeEditor, @@ -23,6 +25,7 @@ export const useSettingsStore = defineStore("settingsStore", { themeType: getStoredActiveTheme(), isFixedHeader: getStoredFixedHeader(), isVisibleEventCounts: getStoredEventsCountVisibility(), + autoDeleteEventsTime: getStoredAutoDeleteEventsTime(), availableEvents: [] as EventType[], }), getters: { @@ -72,6 +75,19 @@ export const useSettingsStore = defineStore("settingsStore", { setStoredEventsCountVisibility(this.isVisibleEventCounts) }, + setAutoDeleteEventsTime(value: string | number | 'none') { + const normalized = + value === 'none' + ? 'none' + : Number(value); + + this.autoDeleteEventsTime = + normalized === 'none' || !Number.isFinite(normalized) || normalized <= 0 + ? 'none' + : normalized; + + setStoredAutoDeleteEventsTime(this.autoDeleteEventsTime); + }, changeActiveCodeEditor(editor: string) { this.codeEditor = editor; diff --git a/src/shared/types/events.ts b/src/shared/types/events.ts index 9ab58c2f..6edd2d1e 100644 --- a/src/shared/types/events.ts +++ b/src/shared/types/events.ts @@ -42,3 +42,15 @@ export interface MappedEventsProps { view: unknown; normalize: (event: ServerEvent) => NormalizedEvent } + +export type EventTypeCount = { + type: EventType; + count: number; +} + +export type EventsPreviewMeta = { + limit: number; + has_more: boolean; + next_cursor: string | null; + grid: unknown[]; +} diff --git a/src/shared/types/local-storage.ts b/src/shared/types/local-storage.ts index f0bc720d..ecb48e05 100644 --- a/src/shared/types/local-storage.ts +++ b/src/shared/types/local-storage.ts @@ -3,6 +3,7 @@ export enum LocalStorageKeys { Theme = "theme", Navbar = "navbar", EventCounts = "event_counts", + AutoDeleteEventsTime = "autodelete_events_time_in_minutes", CodeEditor = "code_editor", Token = "token", } diff --git a/src/shared/ui/dropdown-menu/dropdown-menu.vue b/src/shared/ui/dropdown-menu/dropdown-menu.vue new file mode 100644 index 00000000..ac64f3d2 --- /dev/null +++ b/src/shared/ui/dropdown-menu/dropdown-menu.vue @@ -0,0 +1,173 @@ + + + diff --git a/src/shared/ui/dropdown-menu/index.ts b/src/shared/ui/dropdown-menu/index.ts new file mode 100644 index 00000000..e700b78e --- /dev/null +++ b/src/shared/ui/dropdown-menu/index.ts @@ -0,0 +1,3 @@ +import DropdownMenu from "./dropdown-menu.vue"; + +export { DropdownMenu }; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index fa02b8e8..ec1ba16f 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -9,4 +9,6 @@ export * from './file-attachment'; export * from './pause-button'; export * from './badge-number'; export * from './app-header'; - +export * from './dropdown-menu'; +export * from './select-control'; +export * from './infinite-scroll-trigger'; diff --git a/src/shared/ui/infinite-scroll-trigger/index.ts b/src/shared/ui/infinite-scroll-trigger/index.ts new file mode 100644 index 00000000..3044364f --- /dev/null +++ b/src/shared/ui/infinite-scroll-trigger/index.ts @@ -0,0 +1 @@ +export { default as InfiniteScrollTrigger } from './infinite-scroll-trigger.vue' diff --git a/src/shared/ui/infinite-scroll-trigger/infinite-scroll-trigger.vue b/src/shared/ui/infinite-scroll-trigger/infinite-scroll-trigger.vue new file mode 100644 index 00000000..8782dd4c --- /dev/null +++ b/src/shared/ui/infinite-scroll-trigger/infinite-scroll-trigger.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/src/shared/ui/select-control/index.ts b/src/shared/ui/select-control/index.ts new file mode 100644 index 00000000..6a703e86 --- /dev/null +++ b/src/shared/ui/select-control/index.ts @@ -0,0 +1,3 @@ +import SelectControl from "./select-control.vue"; + +export { SelectControl }; diff --git a/src/shared/ui/select-control/select-control.vue b/src/shared/ui/select-control/select-control.vue new file mode 100644 index 00000000..b1efd7df --- /dev/null +++ b/src/shared/ui/select-control/select-control.vue @@ -0,0 +1,140 @@ + + + diff --git a/src/widgets/ui/layout-preview-events/layout-preview-events.vue b/src/widgets/ui/layout-preview-events/layout-preview-events.vue index 636bb097..8f104313 100644 --- a/src/widgets/ui/layout-preview-events/layout-preview-events.vue +++ b/src/widgets/ui/layout-preview-events/layout-preview-events.vue @@ -1,11 +1,13 @@ @@ -88,6 +111,10 @@ watchEffect(() => { @apply bg-gray-50 dark:bg-gray-800 mb-[10vh]; } +.layout-preview-events__infinite-trigger { + @apply h-6 w-full; +} + .layout-preview-events__btn-stop-events { @apply mr-3 text-xs text-gray-800 dark:text-white rounded-sm hover:opacity-100 transition-all duration-300 opacity-40 relative; diff --git a/src/widgets/ui/layout-preview-events/use-events-pagination.ts b/src/widgets/ui/layout-preview-events/use-events-pagination.ts new file mode 100644 index 00000000..685ad4c3 --- /dev/null +++ b/src/widgets/ui/layout-preview-events/use-events-pagination.ts @@ -0,0 +1,284 @@ +import { computed, onBeforeUnmount, onUpdated, ref, type Ref, watch } from 'vue' +import { PAGE_TYPES } from '@/shared/constants' +import type { TUseEventsApi } from '@/shared/lib/use-events/use-events-api' +import { useEventsStore } from '@/shared/stores' +import type { TEventsCachedIdsMap } from '@/shared/stores/events/types' +import { type EventType, EventTypes, type PageEventTypes } from '@/shared/types' + +type UsePreviewEventsLazyLoadingParams = { + type: Ref + events: TUseEventsApi + cachedEvents: { + idsByType: Ref + } + listEl: Ref +} + +/** + * Notes (data loading & scroll handling) + * - Uses ResizeObserver to recompute scroll availability after DOM size changes. + * - If the first page fully fits the viewport (no scroll), schedules an autoload of the next page. + * - If the content is scrollable but very short (maxScrollTop < 200px), forces one extra load. + * - Uses a temporary lock to prevent duplicate loads caused by intermediate ResizeObserver callbacks. + */ +export const useEventsPagination = ({ + type, + events, + cachedEvents, + listEl +}: UsePreviewEventsLazyLoadingParams) => { + const eventsStore = useEventsStore() + + const isLoadingMore = ref(false) + const hasScrollableOverflow = ref(false) + + // If there is no scroll, debounce the autoload: + // the first page may fully fit the viewport, so we fetch the next page. + const noScrollAutoLoadTimer = { id: undefined as number | undefined } + + // Debounces ResizeObserver work so layout can settle before recomputing scroll state. + const resizeDebounceTimer = { id: undefined as number | undefined } + + // Temporarily blocks resize handling after a forced load on short scroll ranges. + const unlockResizeHandlingTimer = { id: undefined as number | undefined } + + // Delays bottom-gap correction to avoid fighting with ongoing loading/layout. + const ensureBottomGapTimer = { id: undefined as number | undefined } + + // Prevents back-to-back loadMore calls for the same page type (double triggers). + const lastLoadAtByType = new Map() + const LOAD_THROTTLE_MS = 200 + + const canLoadMore = computed(() => { + if (type.value === PAGE_TYPES.ALL_EVENT_TYPES) { + return Object.values(EventTypes).some( + (eventType) => eventsStore.previewPagination[eventType]?.hasMore + ) + } + + return eventsStore.previewPagination[type.value as EventTypes]?.hasMore ?? false + }) + + const getDocumentScroller = (): HTMLElement | null => { + return document.scrollingElement as HTMLElement | null + } + + const getMaxScrollTop = (el: HTMLElement): number => el.scrollHeight - el.clientHeight + + /** + * Sometimes events move up due to sorting, leaving the user stuck at the very bottom. + * If loading is still available, keep a gap from the bottom to ensure the infinite trigger can fire. + */ + const ensureBottomGap = (): void => { + const gapPx = 500 + const el = getDocumentScroller() + if (!el) return + + const maxScrollTop = getMaxScrollTop(el) + if (maxScrollTop === 0) { + return + } + + const distanceToBottom = maxScrollTop - el.scrollTop + if (distanceToBottom >= gapPx) { + return + } + + el.scrollTop = Math.max(0, maxScrollTop - gapPx) + } + + /** + * When `force` is false, `loadMore()` won't run if there is no scroll at all. + * When `force` is true, it bypasses the "no-scroll" guard (used for bootstrap loads). + */ + const loadMore = async (force = false): Promise => { + const pageType = type.value + const now = Date.now() + const lastLoadAt = lastLoadAtByType.get(pageType) + + // Throttle rapid repeated loads per page type to avoid duplicate fetches + if (lastLoadAt !== undefined && now - lastLoadAt < LOAD_THROTTLE_MS) { + return + } + + if (!force) { + const el = getDocumentScroller() + if (!el) return + + const maxScrollTop = getMaxScrollTop(el) + if (maxScrollTop <= 0) { + return + } + } + + if (isLoadingMore.value || !canLoadMore.value) { + return + } + + isLoadingMore.value = true + lastLoadAtByType.set(pageType, now) + + try { + if (pageType === PAGE_TYPES.ALL_EVENT_TYPES) { + const responses = await Promise.all( + Object.values(EventTypes).map(async (eventType) => await events.loadMoreByType(eventType)) + ) + + if (cachedEvents.idsByType.value[PAGE_TYPES.ALL_EVENT_TYPES]?.length) { + const loadedIds = responses.flatMap(({ data }) => data.map(({ uuid }) => uuid)) + eventsStore.appendCachedIds(PAGE_TYPES.ALL_EVENT_TYPES, loadedIds) + } + + setManagedTimeout(ensureBottomGapTimer, () => { + if (canLoadMore.value && hasScrollableOverflow.value) { + ensureBottomGap() + } + }, 200) + + return + } + + await events.loadMoreByType(type.value as EventType) + } finally { + isLoadingMore.value = false + } + } + + /** + * Prevent duplicate loads caused by intermediate ResizeObserver callbacks + * while the DOM is still settling (e.g., placeholder height, fonts, etc.). + */ + let isResizeHandlingLocked = false + + const scheduleAutoLoadWhenNoScroll = (el: HTMLElement): void => { + setManagedTimeout(noScrollAutoLoadTimer, async () => { + const maxScrollTop = getMaxScrollTop(el) + + // Still no scroll after debounce => likely first page fully fits the viewport. + if (maxScrollTop <= 0) { + hasScrollableOverflow.value = false + await loadMore(true) + } + }, 200) + } + + const computeScrollStateAndMaybeLoad = async (): Promise => { + const el = getDocumentScroller() + if (!el) { + hasScrollableOverflow.value = false + return + } + + const maxScrollTop = getMaxScrollTop(el) + + if (maxScrollTop <= 0) { + // No scroll at all. + hasScrollableOverflow.value = false + + // Autoload next page if the first page fits the viewport. + scheduleAutoLoadWhenNoScroll(el) + return + } + + hasScrollableOverflow.value = true + + // Scroll exists but is too small: force one more load. + if (maxScrollTop < 200) { + isResizeHandlingLocked = true + await loadMore(true) + + // Keep current behavior: unlock after a short delay. + setManagedTimeout(unlockResizeHandlingTimer, () => { + isResizeHandlingLocked = false + }, 1000) + } + } + + let resizeObserver: ResizeObserver | null = null + + let rafId = 0 + const RESIZE_DEBOUNCE_MS = 100 + + const setupResizeObserver = (): void => { + resizeObserver?.disconnect() + + resizeObserver = new ResizeObserver(() => { + // We intentionally add a small delay (RESIZE_DEBOUNCE_MS) + // to allow the browser to finish layout and render the scroll state. + setManagedTimeout(resizeDebounceTimer, () => { + cancelAnimationFrame(rafId) + + rafId = requestAnimationFrame(() => { + if (isResizeHandlingLocked) return + void computeScrollStateAndMaybeLoad() + }) + }, RESIZE_DEBOUNCE_MS) + }) + + if (listEl.value) { + resizeObserver.observe(listEl.value) + } + } + + const clearManagedTimeout = (timerRef: { id: number | undefined }): void => { + if (timerRef.id !== undefined) { + clearTimeout(timerRef.id) + timerRef.id = undefined + } + } + + const setManagedTimeout = ( + timerRef: { id: number | undefined }, + cb: () => void | Promise, + delayMs: number + ): void => { + clearManagedTimeout(timerRef) + + timerRef.id = window.setTimeout(() => { + timerRef.id = undefined + void cb() + }, delayMs) + } + + const teardownResizeObserver = (): void => { + hasScrollableOverflow.value = false + + clearManagedTimeout(resizeDebounceTimer) + clearManagedTimeout(noScrollAutoLoadTimer) + clearManagedTimeout(unlockResizeHandlingTimer) + clearManagedTimeout(ensureBottomGapTimer) + + cancelAnimationFrame(rafId) + rafId = 0 + + resizeObserver?.disconnect() + resizeObserver = null + } + + watch( + () => type.value, + () => { + teardownResizeObserver() + setManagedTimeout(ensureBottomGapTimer, () => { + if (canLoadMore.value) { + ensureBottomGap() + } + }, 200) + }, + ) + + onUpdated(() => { + setupResizeObserver() + }) + + onBeforeUnmount(() => { + teardownResizeObserver() + }) + + return { + canLoadMore, + isLoadingMore, + hasScrollableOverflow, + loadMore + } +}