Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions apps/mesh/src/web/hooks/use-infinite-scroll.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { useRef } from "react";
import { useCallback, useRef } from "react";

/**
* Hook for infinite scroll functionality using IntersectionObserver.
*
* @param onLoadMore - Callback function to load more items
* @param hasMore - Whether there are more items to load
* @param isLoading - Whether data is currently loading (prevents duplicate triggers)
* @returns A ref callback to attach to the last element in the list
*
* @example
* ```tsx
* const lastElementRef = useInfiniteScroll(
* () => setPage(p => p + 1),
* items.length >= pageSize
* items.length >= pageSize,
* isFetching
* );
*
* return items.map((item, index) => (
Expand All @@ -27,30 +29,38 @@ import { useRef } from "react";
export function useInfiniteScroll(
onLoadMore: () => void,
hasMore: boolean,
isLoading = false,
): (node: HTMLElement | null) => void {
const observerRef = useRef<IntersectionObserver | null>(null);

// Use refs to always access the latest values inside the observer callback
const onLoadMoreRef = useRef(onLoadMore);
const hasMoreRef = useRef(hasMore);
const isLoadingRef = useRef(isLoading);
onLoadMoreRef.current = onLoadMore;
hasMoreRef.current = hasMore;
isLoadingRef.current = isLoading;

const lastElementRef = (node: HTMLElement | null) => {
// Memoize the ref callback to prevent unnecessary observer recreation
const lastElementRef = useCallback((node: HTMLElement | null) => {
if (observerRef.current) {
observerRef.current.disconnect();
}

observerRef.current = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting && hasMoreRef.current) {
if (
entries[0]?.isIntersecting &&
hasMoreRef.current &&
!isLoadingRef.current
) {
onLoadMoreRef.current();
}
});

if (node) {
observerRef.current.observe(node);
}
};
}, []);

return lastElementRef;
}
126 changes: 63 additions & 63 deletions apps/mesh/src/web/routes/orgs/monitoring.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import { useConnections } from "@/web/hooks/collections/use-connection";
import { useGateways } from "@/web/hooks/collections/use-gateway";
import { useInfiniteScroll } from "@/web/hooks/use-infinite-scroll.ts";
import { useMembers } from "@/web/hooks/use-members";
import { useToolCall } from "@/web/hooks/use-tool-call";
import { useProjectContext } from "@/web/providers/project-context-provider";
import { Badge } from "@deco/ui/components/badge.tsx";
import { Button } from "@deco/ui/components/button.tsx";
Expand All @@ -46,6 +45,7 @@ import {
type TimeRange as TimeRangeValue,
} from "@deco/ui/components/time-range-picker.tsx";
import { expressionToDate } from "@deco/ui/lib/time-expressions.ts";
import { useSuspenseInfiniteQuery } from "@tanstack/react-query";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { Suspense, useState } from "react";
import {
Expand All @@ -61,22 +61,24 @@ import {
interface MonitoringStatsProps {
displayDateRange: DateRange;
connectionIds: string[];
logsData: MonitoringLogsResponse;
logs: MonitoringLogsResponse["logs"];
total?: number;
}

function MonitoringStatsContent({
displayDateRange,
connectionIds,
logsData,
logs: allLogs,
total,
}: MonitoringStatsProps) {
// Filter logs by multiple connection IDs (client-side if more than one selected)
let logs = logsData?.logs ?? [];
let logs = allLogs;
if (connectionIds.length > 1) {
logs = logs.filter((log) => connectionIds.includes(log.connectionId));
}

// Use server total for stats calculation (logs are paginated, so we need the total)
const totalCalls = connectionIds.length > 1 ? undefined : logsData?.total;
const totalCalls = connectionIds.length > 1 ? undefined : total;
const stats = calculateStats(logs, displayDateRange, undefined, totalCalls);

return (
Expand Down Expand Up @@ -248,10 +250,10 @@ interface MonitoringLogsTableProps {
tool: string;
status: string;
search: string;
pageSize: number;
page: number;
logsData: MonitoringLogsResponse;
onPageChange: (page: number) => void;
logs: MonitoringLogsResponse["logs"];
hasMore: boolean;
onLoadMore: () => void;
isLoadingMore: boolean;
connections: ReturnType<typeof useConnections>;
gateways: ReturnType<typeof useGateways>;
membersData: ReturnType<typeof useMembers>["data"];
Expand All @@ -263,10 +265,10 @@ function MonitoringLogsTableContent({
tool,
status,
search: searchQuery,
pageSize,
page,
logsData,
onPageChange,
logs,
hasMore,
onLoadMore,
isLoadingMore,
connections: connectionsData,
gateways: gatewaysData,
membersData,
Expand All @@ -275,14 +277,8 @@ function MonitoringLogsTableContent({
const gateways = gatewaysData ?? [];
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());

// Get logs from the current page
const logs = logsData?.logs ?? [];

// Check if there are more pages available
const hasMore = logs.length >= pageSize;

// Use the infinite scroll hook
const lastLogRef = useInfiniteScroll(() => onPageChange(page + 1), hasMore);
// Use the infinite scroll hook with loading guard
const lastLogRef = useInfiniteScroll(onLoadMore, hasMore, isLoadingMore);

const members = membersData?.data?.members ?? [];
const userMap = new Map(members.map((m) => [m.userId, m.user]));
Expand Down Expand Up @@ -469,7 +465,6 @@ interface MonitoringDashboardContentProps {
activeFiltersCount: number;
from: string;
to: string;
page: number;
onUpdateFilters: (updates: Partial<MonitoringSearchParams>) => void;
onTimeRangeChange: (range: TimeRangeValue) => void;
onStreamingToggle: () => void;
Expand All @@ -487,7 +482,6 @@ function MonitoringDashboardContent({
activeFiltersCount,
from,
to,
page,
onUpdateFilters,
onTimeRangeChange,
onStreamingToggle,
Expand All @@ -506,39 +500,58 @@ function MonitoringDashboardContent({
}));

const { pageSize, streamingRefetchInterval } = MONITORING_CONFIG;
const offset = page * pageSize;

// Single fetch for current page logs
const { locator } = useProjectContext();
const toolCaller = createToolCaller();

const logsParams = {
// Base params for filtering (without pagination)
const baseParams = {
startDate: dateRange.startDate.toISOString(),
endDate: dateRange.endDate.toISOString(),
// Only pass single connection/gateway to API; multi-selection is filtered client-side
connectionId: connectionIds.length === 1 ? connectionIds[0] : undefined,
gatewayId: gatewayIds.length === 1 ? gatewayIds[0] : undefined,
toolName: tool || undefined,
isError:
status === "errors" ? true : status === "success" ? false : undefined,
limit: pageSize,
offset,
};

const { data: logsData } = useToolCall<
typeof logsParams,
MonitoringLogsResponse
>({
toolCaller,
toolName: "MONITORING_LOGS_LIST",
toolInputParams: logsParams,
scope: locator,
staleTime: 0,
refetchInterval: isStreaming ? streamingRefetchInterval : false,
});
// Use React Query's infinite query for automatic accumulation
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useSuspenseInfiniteQuery({
queryKey: [
"monitoring-logs-infinite",
locator,
JSON.stringify(baseParams),
],
queryFn: async ({ pageParam = 0 }) => {
const result = await toolCaller("MONITORING_LOGS_LIST", {
...baseParams,
limit: pageSize,
offset: pageParam,
});
return result as MonitoringLogsResponse;
},
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) => {
// If we got fewer logs than pageSize, there are no more pages
if ((lastPage?.logs?.length ?? 0) < pageSize) {
return undefined;
}
// Otherwise, return the next offset
return allPages.length * pageSize;
},
staleTime: 0,
refetchInterval: isStreaming ? streamingRefetchInterval : false,
});

const handlePageChange = (newPage: number) => {
onUpdateFilters({ page: newPage });
// Flatten all pages into a single array
const allLogs = data?.pages.flatMap((page) => page?.logs ?? []) ?? [];
const total = data?.pages[0]?.total;

// Handler for loading more
const handleLoadMore = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};

return (
Expand Down Expand Up @@ -590,7 +603,8 @@ function MonitoringDashboardContent({
<MonitoringStats
displayDateRange={displayDateRange}
connectionIds={connectionIds}
logsData={logsData}
logs={allLogs}
total={total}
/>

{/* Search Bar */}
Expand All @@ -615,10 +629,10 @@ function MonitoringDashboardContent({
tool={tool}
status={status}
search={searchQuery}
pageSize={pageSize}
page={page}
logsData={logsData}
onPageChange={handlePageChange}
logs={allLogs}
hasMore={hasNextPage ?? false}
onLoadMore={handleLoadMore}
isLoadingMore={isFetchingNextPage}
connections={allConnections}
gateways={allGateways}
membersData={membersData}
Expand All @@ -644,30 +658,17 @@ export default function MonitoringDashboard() {
tool,
search: searchQuery,
status,
page = 0,
streaming = true,
} = search;

// Update URL with new filter values
// Update URL with new filter values (pagination is handled internally, not in URL)
const updateFilters = (updates: Partial<MonitoringSearchParams>) => {
// Reset page to 0 when filters change (unless page is explicitly updated)
const shouldResetPage =
!("page" in updates) &&
("from" in updates ||
"to" in updates ||
"connectionId" in updates ||
"gatewayId" in updates ||
"tool" in updates ||
"status" in updates ||
"search" in updates);

navigate({
to: "/$org/monitoring",
params: { org: org.slug },
search: {
...search,
...updates,
...(shouldResetPage && { page: 0 }),
},
});
};
Expand Down Expand Up @@ -747,7 +748,6 @@ export default function MonitoringDashboard() {
activeFiltersCount={activeFiltersCount}
from={from}
to={to}
page={page}
onUpdateFilters={updateFilters}
onTimeRangeChange={handleTimeRangeChange}
onStreamingToggle={() => updateFilters({ streaming: !streaming })}
Expand Down
Loading