diff --git a/src/pages/audit-report/types/index.ts b/src/pages/audit-report/types/index.ts index 614ea8954..c523c1bf1 100644 --- a/src/pages/audit-report/types/index.ts +++ b/src/pages/audit-report/types/index.ts @@ -417,7 +417,7 @@ export interface ColumnFilterOptions { } export interface ViewRef { - namespace: string; + namespace?: string; name: string; } diff --git a/src/pages/config/details/ConfigDetailsViewPage.tsx b/src/pages/config/details/ConfigDetailsViewPage.tsx index dc19c6e50..7c1abda04 100644 --- a/src/pages/config/details/ConfigDetailsViewPage.tsx +++ b/src/pages/config/details/ConfigDetailsViewPage.tsx @@ -1,70 +1,28 @@ import { ConfigDetailsTabs } from "@flanksource-ui/components/Configs/ConfigDetailsTabs"; -import { - getViewDataById, - getViewDisplayPluginVariables -} from "@flanksource-ui/api/services/views"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useParams } from "react-router-dom"; import { Loading } from "@flanksource-ui/ui/Loading"; -import View from "@flanksource-ui/pages/audit-report/components/View/View"; -import { useRef } from "react"; -import { toastError } from "@flanksource-ui/components/Toast/toast"; +import { useViewData } from "@flanksource-ui/pages/views/hooks/useViewData"; +import ViewWithSections from "@flanksource-ui/pages/views/components/ViewWithSections"; +import { ErrorViewer } from "@flanksource-ui/components/ErrorViewer"; -// Displays the view as a tab in the config details page export function ConfigDetailsViewPage() { - const queryClient = useQueryClient(); - const forceRefreshRef = useRef(false); - const { id: configId, viewId } = useParams<{ id: string; viewId: string; }>(); - // Fetch display plugin variables from the API - const { data: variables } = useQuery({ - queryKey: ["viewDisplayPluginVariables", viewId, configId], - queryFn: () => getViewDisplayPluginVariables(viewId!, configId!), - enabled: !!viewId && !!configId - }); - const { - data: viewResult, + viewResult, isLoading, error, - refetch - } = useQuery({ - queryKey: ["viewDataById", viewId, configId, variables], - queryFn: () => { - if (!viewId) { - throw new Error("View ID is required"); - } - const headers = forceRefreshRef.current - ? { "cache-control": "max-age=1" } - : undefined; - - return getViewDataById(viewId, variables, headers); - }, - enabled: !!viewId && !!configId && !!variables + aggregatedVariables, + currentVariables, + handleForceRefresh + } = useViewData({ + viewId: viewId!, + configId }); - const handleRefresh = async () => { - forceRefreshRef.current = true; - const result = await refetch(); - forceRefreshRef.current = false; - - if (result.isError) { - toastError( - result.error instanceof Error - ? result.error.message - : "Failed to refresh view" - ); - } else if (result.data?.namespace && result.data?.name) { - await queryClient.invalidateQueries({ - queryKey: ["view-table", result.data.namespace, result.data.name] - }); - } - }; - const displayName = viewResult?.title || viewResult?.name || ""; return ( @@ -72,35 +30,23 @@ export function ConfigDetailsViewPage() { pageTitlePrefix={`Config View - ${displayName}`} isLoading={isLoading} activeTabName={displayName} - refetch={handleRefresh} + refetch={handleForceRefresh} > -
+
{isLoading ? (
) : error ? (
-
-
Error
-

- {error instanceof Error - ? error.message - : "Failed to load view data"} -

-
+
) : viewResult ? ( - ) : (
diff --git a/src/pages/views/components/SingleView.tsx b/src/pages/views/components/SingleView.tsx index ff0162e81..e83d1ee48 100644 --- a/src/pages/views/components/SingleView.tsx +++ b/src/pages/views/components/SingleView.tsx @@ -1,15 +1,8 @@ -import React, { useMemo, useRef } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { getViewDataById } from "../../../api/services/views"; -import ViewSection from "./ViewSection"; +import React from "react"; import Age from "../../../ui/Age/Age"; -import { toastError } from "../../../components/Toast/toast"; import ViewLayout from "./ViewLayout"; -import { useAggregatedViewVariables } from "../hooks/useAggregatedViewVariables"; -import GlobalFiltersForm from "../../audit-report/components/View/GlobalFiltersForm"; -import GlobalFilters from "../../audit-report/components/View/GlobalFilters"; -import { VIEW_VAR_PREFIX } from "../constants"; -import type { ViewRef } from "../../audit-report/types"; +import ViewWithSections from "./ViewWithSections"; +import { useViewData } from "../hooks/useViewData"; import { ErrorViewer } from "@flanksource-ui/components/ErrorViewer"; interface SingleViewProps { @@ -17,96 +10,15 @@ interface SingleViewProps { } const SingleView: React.FC = ({ id }) => { - const queryClient = useQueryClient(); - const forceRefreshRef = useRef(false); - - // Fetch view metadata only. Each section (including the main view) will fetch - // its own data with its own variables using its unique prefix. const { - data: viewResult, + viewResult, isLoading, isFetching, error, - refetch - } = useQuery({ - queryKey: ["view-result", id], - queryFn: () => { - const headers = forceRefreshRef.current - ? { "cache-control": "max-age=1" } - : undefined; - return getViewDataById(id, undefined, headers); - }, - enabled: !!id, - staleTime: 5 * 60 * 1000, - keepPreviousData: true - }); - - // Collect all section refs (main view + additional sections) - // Must be called before early returns to satisfy React hooks rules - const allSectionRefs = useMemo(() => { - if (!viewResult?.namespace || !viewResult?.name) { - return []; - } - const refs = [{ namespace: viewResult.namespace, name: viewResult.name }]; - if (viewResult?.sections) { - viewResult.sections.forEach((section) => { - refs.push({ - namespace: section.viewRef.namespace, - name: section.viewRef.name - }); - }); - } - return refs; - }, [viewResult?.namespace, viewResult?.name, viewResult?.sections]); - - // Fetch and aggregate variables from all sections - const { variables: aggregatedVariables, currentVariables } = - useAggregatedViewVariables(allSectionRefs); - - const handleForceRefresh = async () => { - forceRefreshRef.current = true; - const result = await refetch(); - forceRefreshRef.current = false; - - if (result.isError) { - const err = result.error as any; - toastError( - err?.message || - err?.error || - err?.detail || - err?.msg || - "Failed to refresh view" - ); - return; - } - - // Invalidate all section data (view results and tables) so they refetch - const sectionsToRefresh = - allSectionRefs.length > 0 && - allSectionRefs[0].namespace && - allSectionRefs[0].name - ? allSectionRefs - : result.data?.namespace && result.data.name - ? [{ namespace: result.data.namespace, name: result.data.name }] - : []; - - // Also clear the main view query by id so the metadata refetches - await queryClient.invalidateQueries({ queryKey: ["view-result", id] }); - - await Promise.all( - sectionsToRefresh.flatMap((section) => [ - queryClient.invalidateQueries({ - queryKey: ["view-result", section.namespace, section.name] - }), - queryClient.invalidateQueries({ - queryKey: ["view-table", section.namespace, section.name] - }), - queryClient.invalidateQueries({ - queryKey: ["view-variables", section.namespace, section.name] - }) - ]) - ); - }; + aggregatedVariables, + currentVariables, + handleForceRefresh + } = useViewData({ viewId: id }); if (isLoading && !viewResult) { return ( @@ -153,24 +65,7 @@ const SingleView: React.FC = ({ id }) => { ); } - const { icon, title, namespace, name } = viewResult; - - // Render the main view through ViewSection to reuse its spacing/scroll styling; - // rendering the raw View here caused padding/overflow glitches alongside sections. - const primaryViewSection = { - title: title || name, - viewRef: { - namespace: namespace || "", - name: name - } - }; - - // Check if this view only aggregates other views (has sections but no content of its own) - const isAggregatorView = - viewResult.sections && - viewResult.sections.length > 0 && - !viewResult.panels && - !viewResult.columns; + const { icon, title, name } = viewResult; return ( = ({ id }) => { ) } > -
- {/* Render aggregated variables once at the top */} - {aggregatedVariables && aggregatedVariables.length > 0 && ( - - - - )} - - {aggregatedVariables && aggregatedVariables.length > 0 && ( -
- )} - - {/* Only show the primary ViewSection if this view has its own content */} - {!isAggregatorView && ( -
- -
- )} - - {viewResult?.sections && viewResult.sections.length > 0 && ( - <> - {viewResult.sections.map((section) => ( -
- -
- ))} - - )} -
+
); }; diff --git a/src/pages/views/components/ViewSection.tsx b/src/pages/views/components/ViewSection.tsx index e7d173ca8..ed858ca98 100644 --- a/src/pages/views/components/ViewSection.tsx +++ b/src/pages/views/components/ViewSection.tsx @@ -35,7 +35,7 @@ const ViewSection: React.FC = ({ } = useQuery({ queryKey: ["view-result", namespace, name, currentViewVariables], queryFn: () => - getViewDataByNamespace(namespace, name, currentViewVariables), + getViewDataByNamespace(namespace || "", name, currentViewVariables), enabled: !!namespace && !!name, staleTime: 5 * 60 * 1000 }); diff --git a/src/pages/views/components/ViewWithSections.tsx b/src/pages/views/components/ViewWithSections.tsx new file mode 100644 index 000000000..68ed3aca5 --- /dev/null +++ b/src/pages/views/components/ViewWithSections.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import ViewSection from "./ViewSection"; +import GlobalFiltersForm from "../../audit-report/components/View/GlobalFiltersForm"; +import GlobalFilters from "../../audit-report/components/View/GlobalFilters"; +import { VIEW_VAR_PREFIX } from "../constants"; +import type { ViewResult, ViewVariable } from "../../audit-report/types"; + +interface ViewWithSectionsProps { + className?: string; + viewResult: ViewResult; + aggregatedVariables?: ViewVariable[]; + currentVariables?: Record; + hideVariables?: boolean; +} + +const ViewWithSections: React.FC = ({ + viewResult, + className, + aggregatedVariables, + currentVariables, + hideVariables +}) => { + const { namespace, name, panels, columns } = viewResult; + + const isAggregatorView = + viewResult.sections && + viewResult.sections.length > 0 && + !panels && + !columns; + + const primaryViewSection = { + title: viewResult.title || name, + viewRef: { + namespace: namespace || "", + name: name + } + }; + + const showVariables = + !hideVariables && aggregatedVariables && aggregatedVariables.length > 0; + + return ( +
+ {showVariables && ( + + + + )} + + {showVariables &&
} + + {!isAggregatorView && ( +
+ +
+ )} + + {viewResult?.sections && viewResult.sections.length > 0 && ( + <> + {viewResult.sections.map((section) => ( +
+ +
+ ))} + + )} +
+ ); +}; + +export default ViewWithSections; diff --git a/src/pages/views/hooks/useAggregatedViewVariables.ts b/src/pages/views/hooks/useAggregatedViewVariables.ts index 16fa1f394..887537c78 100644 --- a/src/pages/views/hooks/useAggregatedViewVariables.ts +++ b/src/pages/views/hooks/useAggregatedViewVariables.ts @@ -31,7 +31,7 @@ export function useAggregatedViewVariables(sections: ViewRef[]) { ], queryFn: () => getViewDataByNamespace( - section.namespace, + section.namespace || "", section.name, currentVariables ), diff --git a/src/pages/views/hooks/useViewData.ts b/src/pages/views/hooks/useViewData.ts new file mode 100644 index 000000000..d2fcff12f --- /dev/null +++ b/src/pages/views/hooks/useViewData.ts @@ -0,0 +1,168 @@ +import { useRef, useMemo, useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + getViewDataById, + getViewDisplayPluginVariables +} from "../../../api/services/views"; +import { useAggregatedViewVariables } from "./useAggregatedViewVariables"; +import { toastError } from "../../../components/Toast/toast"; +import type { + ViewRef, + ViewResult, + ViewVariable +} from "../../audit-report/types"; + +export interface UseViewDataOptions { + viewId: string; + configId?: string; +} + +export interface UseViewDataResult { + viewResult: ViewResult | undefined; + isLoading: boolean; + isFetching: boolean; + error: unknown; + aggregatedVariables: ViewVariable[]; + currentVariables: Record; + handleForceRefresh: () => Promise; +} + +export function useViewData({ + viewId, + configId +}: UseViewDataOptions): UseViewDataResult { + const queryClient = useQueryClient(); + const forceRefreshRef = useRef(false); + + const isDisplayPluginMode = !!configId; + + const { + data: displayPluginVariables, + isLoading: isLoadingDisplayPluginVariables, + isFetching: isFetchingDisplayPluginVariables, + error: displayPluginVariablesError + } = useQuery({ + queryKey: ["viewDisplayPluginVariables", viewId, configId], + queryFn: () => getViewDisplayPluginVariables(viewId, configId!), + enabled: isDisplayPluginMode + }); + + const variables = isDisplayPluginMode ? displayPluginVariables : undefined; + + const { + data: viewResult, + isLoading: isLoadingViewResult, + isFetching: isFetchingViewResult, + error: viewResultError, + refetch + } = useQuery({ + queryKey: isDisplayPluginMode + ? ["viewDataById", viewId, configId, variables] + : ["view-result", viewId], + queryFn: () => { + const headers = forceRefreshRef.current + ? { "cache-control": "max-age=1" } + : undefined; + return getViewDataById(viewId, variables, headers); + }, + enabled: isDisplayPluginMode ? !!variables : !!viewId, + staleTime: 5 * 60 * 1000, + keepPreviousData: true + }); + + const allSectionRefs = useMemo(() => { + if (!viewResult?.name) { + return []; + } + const refs: ViewRef[] = [ + { namespace: viewResult.namespace ?? "", name: viewResult.name } + ]; + if (viewResult?.sections) { + viewResult.sections.forEach((section) => { + refs.push({ + namespace: section.viewRef.namespace ?? "", + name: section.viewRef.name + }); + }); + } + return refs; + }, [viewResult?.namespace, viewResult?.name, viewResult?.sections]); + + const { + variables: aggregatedVariables, + currentVariables: aggregatedCurrentVariables + } = useAggregatedViewVariables(allSectionRefs); + + const currentVariables = isDisplayPluginMode + ? (variables ?? {}) + : aggregatedCurrentVariables; + + const handleForceRefresh = useCallback(async () => { + forceRefreshRef.current = true; + const result = await refetch(); + forceRefreshRef.current = false; + + if (result.isError) { + const err = result.error as any; + toastError( + err?.message || + err?.error || + err?.detail || + err?.msg || + "Failed to refresh view" + ); + return; + } + + const sectionsToRefresh = + allSectionRefs.length > 0 && allSectionRefs[0].name + ? allSectionRefs + : result.data?.name + ? [{ namespace: result.data.namespace ?? "", name: result.data.name }] + : []; + + if (isDisplayPluginMode) { + await queryClient.invalidateQueries({ + queryKey: ["viewDisplayPluginVariables", viewId, configId] + }); + } + + await queryClient.invalidateQueries({ + queryKey: isDisplayPluginMode + ? ["viewDataById", viewId, configId, variables] + : ["view-result", viewId] + }); + + await Promise.all( + sectionsToRefresh.flatMap((section) => [ + queryClient.invalidateQueries({ + queryKey: ["view-result", section.namespace, section.name] + }), + queryClient.invalidateQueries({ + queryKey: ["view-table", section.namespace, section.name] + }), + queryClient.invalidateQueries({ + queryKey: ["view-variables", section.namespace, section.name] + }) + ]) + ); + }, [ + allSectionRefs, + configId, + isDisplayPluginMode, + queryClient, + refetch, + variables, + viewId + ]); + + return { + viewResult, + isLoading: isLoadingViewResult || isLoadingDisplayPluginVariables, + isFetching: isFetchingViewResult || isFetchingDisplayPluginVariables, + error: displayPluginVariablesError || viewResultError, + aggregatedVariables: isDisplayPluginMode ? [] : aggregatedVariables, + currentVariables, + handleForceRefresh + }; +}