diff --git a/src/api/query-hooks/useConfigChangesHooks.ts b/src/api/query-hooks/useConfigChangesHooks.ts index 65e6586da..879d5eb76 100644 --- a/src/api/query-hooks/useConfigChangesHooks.ts +++ b/src/api/query-hooks/useConfigChangesHooks.ts @@ -39,9 +39,11 @@ function useConfigChangesTagsFilter(paramPrefix?: string) { export function useGetAllConfigsChangesQuery( { paramPrefix, + cursor, ...queryOptions }: UseQueryOptions & { paramPrefix?: string; + cursor?: string; } = { enabled: true, keepPreviousData: true @@ -73,8 +75,8 @@ export function useGetAllConfigsChangesQuery( include_deleted_configs: showChangesFromDeletedConfigs, changeType, severity, - from, - to, + from: cursor ?? from, + to: cursor ? undefined : to, configTypes, configType, sortBy: sortBy[0]?.id, @@ -93,7 +95,12 @@ export function useGetAllConfigsChangesQuery( } export function useGetConfigChangesByIDQuery( - queryOptions: UseQueryOptions = { + { + cursor, + ...queryOptions + }: UseQueryOptions & { + cursor?: string; + } = { enabled: true, keepPreviousData: true } @@ -141,8 +148,8 @@ export function useGetConfigChangesByIDQuery( include_deleted_configs: showChangesFromDeletedConfigs, changeType: change_type, severity, - from, - to, + from: cursor ?? from, + to: cursor ? undefined : to, configTypes, sortBy: sortBy[0]?.id, sortOrder: sortBy[0]?.desc ? "desc" : "asc", diff --git a/src/api/types/configs.ts b/src/api/types/configs.ts index a0cfc4cb7..eeb3215ad 100644 --- a/src/api/types/configs.ts +++ b/src/api/types/configs.ts @@ -23,6 +23,7 @@ export interface ConfigChange extends CreatedAt { tags?: Record; first_observed?: string; count?: number; + inserted_at?: string; } export interface Change { diff --git a/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters.tsx b/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters.tsx index 9b0b05566..ee0e455c1 100644 --- a/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters.tsx +++ b/src/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters.tsx @@ -67,11 +67,13 @@ export function FilterBadge({ filters, paramKey }: FilterBadgeProps) { type ConfigChangeFiltersProps = React.HTMLProps & { paramsToReset?: string[]; + extra?: React.ReactNode; }; export function ConfigChangeFilters({ className, paramsToReset = [], + extra, ...props }: ConfigChangeFiltersProps) { const [params] = useSearchParams(); @@ -100,6 +102,7 @@ export function ConfigChangeFilters({ + {extra}
diff --git a/src/components/Configs/Changes/ConfigsRelatedChanges/FilterBar/ConfigRelatedChangesFilters.tsx b/src/components/Configs/Changes/ConfigsRelatedChanges/FilterBar/ConfigRelatedChangesFilters.tsx index 990e16375..eb16137ce 100644 --- a/src/components/Configs/Changes/ConfigsRelatedChanges/FilterBar/ConfigRelatedChangesFilters.tsx +++ b/src/components/Configs/Changes/ConfigsRelatedChanges/FilterBar/ConfigRelatedChangesFilters.tsx @@ -13,11 +13,13 @@ import ConfigTypesTristateDropdown from "../../ConfigChangesFilters/ConfigTypesT type ConfigChangeFiltersProps = { className?: string; paramsToReset?: string[]; + extra?: React.ReactNode; }; export function ConfigRelatedChangesFilters({ className, - paramsToReset = [] + paramsToReset = [], + extra }: ConfigChangeFiltersProps) { const arbitraryFilters = useConfigChangesArbitraryFilters(); @@ -35,6 +37,7 @@ export function ConfigRelatedChangesFilters({ + {extra}
diff --git a/src/pages/config/ConfigChangesPage.tsx b/src/pages/config/ConfigChangesPage.tsx index 5a614b9d0..1a7230ed8 100644 --- a/src/pages/config/ConfigChangesPage.tsx +++ b/src/pages/config/ConfigChangesPage.tsx @@ -1,4 +1,5 @@ import { useGetAllConfigsChangesQuery } from "@flanksource-ui/api/query-hooks/useConfigChangesHooks"; +import { ConfigChange } from "@flanksource-ui/api/types/configs"; import { ConfigChangeTable } from "@flanksource-ui/components/Configs/Changes/ConfigChangeTable"; import { ConfigChangeFilters } from "@flanksource-ui/components/Configs/Changes/ConfigChangesFilters/ConfigChangesFilters"; import ConfigPageTabs from "@flanksource-ui/components/Configs/ConfigPageTabs"; @@ -12,9 +13,22 @@ import { import { Head } from "@flanksource-ui/ui/Head"; import { SearchLayout } from "@flanksource-ui/ui/Layout/SearchLayout"; import { refreshButtonClickedTrigger } from "@flanksource-ui/ui/SlidingSideBar/SlidingSideBar"; +import { Toggle } from "@flanksource-ui/ui/FormControls/Toggle"; import { useAtom } from "jotai"; +import { useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; +function getNewestCreatedAt(changes: ConfigChange[]): string | undefined { + let latest: string | undefined; + for (const c of changes) { + const ts = typeof c.created_at === "string" ? c.created_at : undefined; + if (ts && (!latest || ts > latest)) { + latest = ts; + } + } + return latest; +} + export function ConfigChangesPage() { const [, setRefreshButtonClickedTrigger] = useAtom( refreshButtonClickedTrigger @@ -31,22 +45,83 @@ export function ConfigChangesPage() { const pageSize = params.get("pageSize") ?? "200"; + const [liveTail, setLiveTail] = useState(false); + const [tailCursor, setTailCursor] = useState(undefined); + const [tailedChanges, setTailedChanges] = useState([]); + const { data, isLoading, error, isRefetching, refetch } = useGetAllConfigsChangesQuery({ keepPreviousData: true }); - const changes = (data?.changes ?? []).map((changes) => ({ - ...changes, + // Initialize cursor from base data when live tail is turned on + useEffect(() => { + if (liveTail && data?.changes?.length && !tailCursor) { + setTailCursor(getNewestCreatedAt(data.changes)); + } + }, [liveTail, data, tailCursor]); + + // Reset when live tail is turned off + useEffect(() => { + if (!liveTail) { + setTailedChanges([]); + setTailCursor(undefined); + } + }, [liveTail]); + + const { data: pollData } = useGetAllConfigsChangesQuery({ + cursor: tailCursor, + keepPreviousData: false, + enabled: liveTail && !!tailCursor, + refetchInterval: liveTail ? 5000 : false + }); + + // Accumulate new items from poll and advance cursor + useEffect(() => { + if (!pollData?.changes?.length) return; + + const incoming = pollData.changes; + const newest = getNewestCreatedAt(incoming); + if (newest) { + setTailCursor((prev) => (!prev || newest > prev ? newest : prev)); + } + + setTailedChanges((prev) => { + const incomingIds = new Set(incoming.map((c) => c.id)); + const filtered = prev.filter((c) => !incomingIds.has(c.id)); + return [...incoming, ...filtered]; + }); + }, [pollData]); + + const baseChanges = (data?.changes ?? []).map((change) => ({ + ...change, + config: { + id: change.config_id!, + type: change.type!, + name: change.name!, + deleted_at: change.deleted_at + } + })); + + const tailedWithConfig = tailedChanges.map((change) => ({ + ...change, config: { - id: changes.config_id!, - type: changes.type!, - name: changes.name!, - deleted_at: changes.deleted_at + id: change.config_id!, + type: change.type!, + name: change.name!, + deleted_at: change.deleted_at } })); - const totalChanges = data?.total ?? 0; + const tailedIds = new Set(tailedWithConfig.map((c) => c.id)); + const baseWithoutTailed = baseChanges.filter((c) => !tailedIds.has(c.id)); + const baseIds = new Set(baseChanges.map((c) => c.id)); + const newTailedCount = tailedWithConfig.filter( + (c) => !baseIds.has(c.id) + ).length; + const changes = [...tailedWithConfig, ...baseWithoutTailed]; + + const totalChanges = (data?.total ?? 0) + newTailedCount; const totalChangesPages = Math.ceil(totalChanges / parseInt(pageSize)); const errorMessage = @@ -99,7 +174,16 @@ export function ConfigChangesPage() { ) : ( <> - + + } + /> latest)) { + latest = ts; + } + } + return latest; +} + export function ConfigDetailsChangesPage() { const { id } = useParams(); @@ -15,21 +29,81 @@ export function ConfigDetailsChangesPage() { const pageSize = params.get("pageSize") ?? "200"; + const [liveTail, setLiveTail] = useState(false); + const [tailCursor, setTailCursor] = useState(undefined); + const [tailedChanges, setTailedChanges] = useState([]); + const { data, isLoading, error, refetch } = useGetConfigChangesByIDQuery({ keepPreviousData: true, enabled: !!id }); - const changes = (data?.changes ?? []).map((changes) => ({ - ...changes, + // Initialize cursor from base data when live tail is turned on + useEffect(() => { + if (liveTail && data?.changes?.length && !tailCursor) { + setTailCursor(getNewestCreatedAt(data.changes)); + } + }, [liveTail, data, tailCursor]); + + // Reset when live tail is turned off + useEffect(() => { + if (!liveTail) { + setTailedChanges([]); + setTailCursor(undefined); + } + }, [liveTail]); + + const { data: pollData } = useGetConfigChangesByIDQuery({ + cursor: tailCursor, + keepPreviousData: false, + enabled: liveTail && !!id && !!tailCursor, + refetchInterval: liveTail ? 5000 : false + }); + + // Accumulate new items from poll and advance cursor + useEffect(() => { + if (!pollData?.changes?.length) return; + + const incoming = pollData.changes; + const newest = getNewestCreatedAt(incoming); + if (newest) { + setTailCursor((prev) => (!prev || newest > prev ? newest : prev)); + } + + setTailedChanges((prev) => { + const incomingIds = new Set(incoming.map((c) => c.id)); + const filtered = prev.filter((c) => !incomingIds.has(c.id)); + return [...incoming, ...filtered]; + }); + }, [pollData]); + + const baseChanges = (data?.changes ?? []).map((change) => ({ + ...change, config: { - id: changes.config_id!, - type: changes.type!, - name: changes.name! + id: change.config_id!, + type: change.type!, + name: change.name! } })); - const totalChanges = data?.total ?? 0; + const tailedWithConfig = tailedChanges.map((change) => ({ + ...change, + config: { + id: change.config_id!, + type: change.type!, + name: change.name! + } + })); + + const tailedIds = new Set(tailedWithConfig.map((c) => c.id)); + const baseWithoutTailed = baseChanges.filter((c) => !tailedIds.has(c.id)); + const baseIds = new Set(baseChanges.map((c) => c.id)); + const newTailedCount = tailedWithConfig.filter( + (c) => !baseIds.has(c.id) + ).length; + const changes = [...tailedWithConfig, ...baseWithoutTailed]; + + const totalChanges = (data?.total ?? 0) + newTailedCount; const totalChangesPages = Math.ceil(totalChanges / parseInt(pageSize)); if (error) { @@ -50,7 +124,12 @@ export function ConfigDetailsChangesPage() { >
- + + } + />