diff --git a/src/api/services/views.ts b/src/api/services/views.ts index b453619ad..9232abaf9 100644 --- a/src/api/services/views.ts +++ b/src/api/services/views.ts @@ -70,16 +70,16 @@ export const getAllViews = ( /** * Get the data for a view by its id. */ -export const getViewDataById = async ( - viewId: string, +const fetchViewData = async ( + path: string, variables?: Record, headers?: Record ): Promise => { const body: { variables?: Record } = { - variables: variables + variables }; - const response = await fetch(`/api/view/${viewId}`, { + const response = await fetch(path, { method: "POST", credentials: "include", headers: { @@ -99,6 +99,31 @@ export const getViewDataById = async ( return response.json(); }; +export const getViewDataById = async ( + viewId: string, + variables?: Record, + headers?: Record +): Promise => { + return fetchViewData( + `/api/view/${encodeURIComponent(viewId)}`, + variables, + headers + ); +}; + +export const getViewDataByNamespace = async ( + namespace: string, + name: string, + variables?: Record, + headers?: Record +): Promise => { + return fetchViewData( + `/api/view/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`, + variables, + headers + ); +}; + /** * Get display plugin variables for a view based on a config */ diff --git a/src/pages/audit-report/components/View/View.tsx b/src/pages/audit-report/components/View/View.tsx index 0872f851b..a78196e57 100644 --- a/src/pages/audit-report/components/View/View.tsx +++ b/src/pages/audit-report/components/View/View.tsx @@ -28,6 +28,7 @@ import GlobalFilters from "./GlobalFilters"; import GlobalFiltersForm from "./GlobalFiltersForm"; import { usePrefixedSearchParams } from "../../../../hooks/usePrefixedSearchParams"; import ViewCardsDisplay from "./ViewCardsDisplay"; +import { VIEW_VAR_PREFIX } from "@flanksource-ui/pages/views/constants"; interface ViewProps { title?: string; @@ -43,6 +44,7 @@ interface ViewProps { }; requestFingerprint: string; currentVariables?: Record; + hideVariables?: boolean; } const View: React.FC = ({ @@ -55,7 +57,8 @@ const View: React.FC = ({ variables, card, requestFingerprint, - currentVariables + currentVariables, + hideVariables }) => { const { pageSize } = useReactTablePaginationState(); @@ -66,8 +69,8 @@ const View: React.FC = ({ // Separate display mode state (frontend only, not sent to backend) const [searchParams, setSearchParams] = useSearchParams(); - // Create unique prefix for global filters - const globalVarPrefix = "viewvar"; + // Create unique prefix for global filters (same as ViewSection uses) + const globalVarPrefix = VIEW_VAR_PREFIX; const hasDataTable = columns && columns.length > 0; // Detect if card mode is available (supports both new and old cardPosition field) @@ -161,94 +164,96 @@ const View: React.FC = ({ return ( <> - {title !== "" && ( -

- - {title} -

- )} - - {variables && variables.length > 0 && ( - - - - )} - - {variables && variables.length > 0 && ( -
- )} +
+ {title !== "" && ( +

+ + {title} +

+ )} -
- {panels && panels.length > 0 && ( -
0 && ( + - {groupAndRenderPanels(panels)} -
+ + )} -
- - {hasDataTable && ( -
-
-
- {filterableColumns.map(({ column, options }) => ( - - ))} -
+ {!hideVariables && variables && variables.length > 0 && ( +
+ )} + +
+ {panels && panels.length > 0 && ( +
+ {groupAndRenderPanels(panels)} +
+ )} +
- {/* Display Mode Toggle */} - {hasCardMode && ( -
- - + + {hasDataTable && ( +
+
+
+ {filterableColumns.map(({ column, options }) => ( + + ))}
- )} + + {/* Display Mode Toggle */} + {hasCardMode && ( +
+ + +
+ )} +
-
- )} - + )} + +
{tableError && (
@@ -270,14 +275,16 @@ const View: React.FC = ({ totalRowCount={totalEntries} /> ) : ( - +
+ +
))} ); diff --git a/src/pages/audit-report/types/index.ts b/src/pages/audit-report/types/index.ts index d0c3ab8da..09943547e 100644 --- a/src/pages/audit-report/types/index.ts +++ b/src/pages/audit-report/types/index.ts @@ -376,6 +376,17 @@ export interface ColumnFilterOptions { labels?: Record; } +export interface ViewRef { + namespace: string; + name: string; +} + +export interface ViewSection { + title: string; + icon?: string; + viewRef: ViewRef; +} + export interface ViewResult { title?: string; icon?: string; @@ -390,6 +401,7 @@ export interface ViewResult { variables?: ViewVariable[]; card?: DisplayCard; requestFingerprint: string; + sections?: ViewSection[]; } export interface GaugeThreshold { diff --git a/src/pages/views/components/SingleView.tsx b/src/pages/views/components/SingleView.tsx index 218c3d396..c6dce2fb1 100644 --- a/src/pages/views/components/SingleView.tsx +++ b/src/pages/views/components/SingleView.tsx @@ -1,82 +1,26 @@ -import React, { useRef } from "react"; +import React, { useMemo, useRef } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { getViewDataById } from "../../../api/services/views"; -import { usePrefixedSearchParams } from "../../../hooks/usePrefixedSearchParams"; -import View from "../../audit-report/components/View/View"; -import { Head } from "../../../ui/Head"; -import { Icon } from "../../../ui/Icons/Icon"; -import { SearchLayout } from "../../../ui/Layout/SearchLayout"; -import { BreadcrumbNav, BreadcrumbRoot } from "../../../ui/BreadcrumbNav"; +import ViewSection from "./ViewSection"; 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"; interface SingleViewProps { id: string; } -// This is the prefix for all the query params that are related to the view variables. -const VIEW_VAR_PREFIX = "viewvar"; - -interface ViewLayoutProps { - title: string; - icon: string; - onRefresh: () => void; - loading?: boolean; - extra?: React.ReactNode; - children: React.ReactNode; - centered?: boolean; -} - -const ViewLayout: React.FC = ({ - title, - icon, - onRefresh, - loading, - extra, - children, - centered = false -}) => ( - <> - - - - {title} - - ]} - /> - } - onRefresh={onRefresh} - contentClass="p-0 h-full" - loading={loading} - extra={extra} - > - {centered ? ( -
- {children} -
- ) : ( - children - )} -
- -); - const SingleView: React.FC = ({ id }) => { const queryClient = useQueryClient(); const forceRefreshRef = useRef(false); - // Use prefixed search params for view variables - // NOTE: Backend uses view variables (template parameters) to partition the rows in the view table. - // We must remove the global query parameters from the URL params. - const [viewVarParams] = usePrefixedSearchParams(VIEW_VAR_PREFIX, false); - const currentViewVariables = Object.fromEntries(viewVarParams.entries()); - - // Fetch all the view metadata, panel results and the column definitions - // NOTE: This doesn't fetch the table rows. + // 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, isLoading, @@ -84,18 +28,40 @@ const SingleView: React.FC = ({ id }) => { error, refetch } = useQuery({ - queryKey: ["view-result", id, currentViewVariables], + queryKey: ["view-result", id], queryFn: () => { const headers = forceRefreshRef.current ? { "cache-control": "max-age=1" } : undefined; - return getViewDataById(id, currentViewVariables, headers); + return getViewDataById(id, undefined, headers); }, enabled: !!id, staleTime: 5 * 60 * 1000, - placeholderData: (previousData: any) => previousData + 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(); @@ -107,11 +73,35 @@ const SingleView: React.FC = ({ id }) => { ? 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] - }); + 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] + }) + ]) + ); }; if (isLoading && !viewResult) { @@ -170,6 +160,16 @@ 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: "", + viewRef: { + namespace: namespace || "", + name: name + } + }; + return ( = ({ id }) => { } >
- + {/* Render aggregated variables once at the top */} + {aggregatedVariables && aggregatedVariables.length > 0 && ( + + + + )} + + {aggregatedVariables && aggregatedVariables.length > 0 && ( +
+ )} + +
+ +
+ + {viewResult?.sections && viewResult.sections.length > 0 && ( + <> + {viewResult.sections.map((section) => ( +
+ +
+ ))} + + )}
); diff --git a/src/pages/views/components/ViewLayout.tsx b/src/pages/views/components/ViewLayout.tsx new file mode 100644 index 000000000..803dbcea8 --- /dev/null +++ b/src/pages/views/components/ViewLayout.tsx @@ -0,0 +1,55 @@ +import type React from "react"; +import { Head } from "../../../ui/Head"; +import { Icon } from "../../../ui/Icons/Icon"; +import { SearchLayout } from "../../../ui/Layout/SearchLayout"; +import { BreadcrumbNav, BreadcrumbRoot } from "../../../ui/BreadcrumbNav"; + +interface ViewLayoutProps { + title: string; + icon: string; + onRefresh: () => void; + loading?: boolean; + extra?: React.ReactNode; + children: React.ReactNode; + centered?: boolean; +} + +const ViewLayout: React.FC = ({ + title, + icon, + onRefresh, + loading, + extra, + children, + centered = false +}) => ( + <> + + + + {title} + + ]} + /> + } + onRefresh={onRefresh} + contentClass="p-0 h-full" + loading={loading} + extra={extra} + > + {centered ? ( +
+ {children} +
+ ) : ( + children + )} +
+ +); + +export default ViewLayout; diff --git a/src/pages/views/components/ViewSection.tsx b/src/pages/views/components/ViewSection.tsx new file mode 100644 index 000000000..4c3d8067e --- /dev/null +++ b/src/pages/views/components/ViewSection.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { getViewDataByNamespace } from "../../../api/services/views"; +import View from "../../audit-report/components/View/View"; +import { Icon } from "../../../ui/Icons/Icon"; +import { usePrefixedSearchParams } from "../../../hooks/usePrefixedSearchParams"; +import { VIEW_VAR_PREFIX } from "../constants"; +import { ViewSection as Section } from "../../audit-report/types"; + +interface ViewSectionProps { + section: Section; + hideVariables?: boolean; +} + +const ViewSection: React.FC = ({ + section, + hideVariables +}) => { + const { namespace, name } = section.viewRef; + + // Use prefixed search params for view variables + // NOTE: Backend uses view variables (template parameters) to partition the rows in the view table. + // We must remove the global query parameters from the URL params. + const [viewVarParams] = usePrefixedSearchParams(VIEW_VAR_PREFIX, false); + const currentViewVariables = Object.fromEntries(viewVarParams.entries()); + + // Fetch section view data with independent variables + const { + data: sectionViewResult, + isLoading, + error + } = useQuery({ + queryKey: ["view-result", namespace, name, currentViewVariables], + queryFn: () => + getViewDataByNamespace(namespace, name, currentViewVariables), + enabled: !!namespace && !!name, + staleTime: 5 * 60 * 1000 + }); + + if (isLoading) { + return ( +
+
+
+
+ ); + } + + if (error || !sectionViewResult) { + return ( + <> +
+ {section.icon && ( + + )} +

{section.title}

+
+
+ {error instanceof Error ? error.message : "Failed to load section"} +
+ + ); + } + + return ( + <> +
+ {section.icon && } +

{section.title}

+
+ + + ); +}; + +export default ViewSection; diff --git a/src/pages/views/constants.ts b/src/pages/views/constants.ts new file mode 100644 index 000000000..4faf5a805 --- /dev/null +++ b/src/pages/views/constants.ts @@ -0,0 +1,2 @@ +// Shared constants for views +export const VIEW_VAR_PREFIX = "viewvar"; diff --git a/src/pages/views/hooks/useAggregatedViewVariables.ts b/src/pages/views/hooks/useAggregatedViewVariables.ts new file mode 100644 index 000000000..16fa1f394 --- /dev/null +++ b/src/pages/views/hooks/useAggregatedViewVariables.ts @@ -0,0 +1,53 @@ +import { useMemo } from "react"; +import { useQueries } from "@tanstack/react-query"; +import { getViewDataByNamespace } from "../../../api/services/views"; +import { aggregateVariables } from "../utils/aggregateVariables"; +import { usePrefixedSearchParams } from "../../../hooks/usePrefixedSearchParams"; +import { VIEW_VAR_PREFIX } from "../constants"; +import type { ViewRef } from "../../audit-report/types"; + +export function useAggregatedViewVariables(sections: ViewRef[]) { + const [viewVarParams] = usePrefixedSearchParams(VIEW_VAR_PREFIX, false); + const viewVarParamsString = useMemo( + () => viewVarParams.toString(), + [viewVarParams] + ); + const currentVariables = useMemo( + () => Object.fromEntries(viewVarParams.entries()), + [viewVarParams] + ); + + // Use a stable key for the current variables to avoid needless refetches + const currentVariablesKey = viewVarParamsString; + + // Fetch all sections in parallel + const queries = useQueries({ + queries: sections.map((section) => ({ + queryKey: [ + "view-variables", + section.namespace, + section.name, + currentVariablesKey + ], + queryFn: () => + getViewDataByNamespace( + section.namespace, + section.name, + currentVariables + ), + enabled: !!section.namespace && !!section.name, + staleTime: 5 * 60 * 1000, + keepPreviousData: true + })) + }); + + const isLoading = queries.some((q) => q.isLoading); + const variableArrays = queries.map((q) => q.data?.variables); + const aggregatedVariables = aggregateVariables(variableArrays); + + return { + variables: aggregatedVariables, + isLoading, + currentVariables + }; +} diff --git a/src/pages/views/utils/aggregateVariables.ts b/src/pages/views/utils/aggregateVariables.ts new file mode 100644 index 000000000..429d5a610 --- /dev/null +++ b/src/pages/views/utils/aggregateVariables.ts @@ -0,0 +1,34 @@ +import { ViewVariable } from "../../audit-report/types"; + +/** + * Aggregates variables from multiple views, deduplicating by key. + * When same key appears multiple times, merges options arrays (union) and keeps + * the first definition for other fields to avoid silently overriding labels or defaults. + */ +export function aggregateVariables( + variableArrays: (ViewVariable[] | undefined)[] +): ViewVariable[] { + const variableMap = new Map(); + + for (const variables of variableArrays) { + if (!variables) continue; + + for (const variable of variables) { + const existing = variableMap.get(variable.key); + if (existing) { + // Merge options (union of both arrays) + const mergedOptions = [ + ...new Set([...existing.options, ...variable.options]) + ]; + variableMap.set(variable.key, { + ...existing, + options: mergedOptions + }); + } else { + variableMap.set(variable.key, { ...variable }); + } + } + } + + return Array.from(variableMap.values()); +} diff --git a/src/ui/MRTDataTable/MRTDataTable.tsx b/src/ui/MRTDataTable/MRTDataTable.tsx index 5c2830d9e..702b5c56d 100644 --- a/src/ui/MRTDataTable/MRTDataTable.tsx +++ b/src/ui/MRTDataTable/MRTDataTable.tsx @@ -121,7 +121,7 @@ export default function MRTDataTable = {}>({ }), mantinePaperProps: () => ({ sx: { - flex: "1 1 0", + flex: "1 1 auto", display: "flex", flexFlow: "column" } @@ -130,7 +130,7 @@ export default function MRTDataTable = {}>({ enableExpandAll: enableGrouping, mantineTableContainerProps: { sx: { - flex: "1 1 0" + flex: "1 1 auto" } }, mantineTableBodyCellProps: () => ({