From 3df6dd58d36cc52bbaeeb7e4932641ccf628555f Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Tue, 2 Dec 2025 18:32:28 +0545 Subject: [PATCH 1/9] feat: View sections --- src/api/services/views.ts | 30 +++ .../audit-report/components/View/View.tsx | 190 +++++++++--------- src/pages/audit-report/types/index.ts | 12 ++ src/pages/views/components/SingleView.tsx | 110 +++------- src/pages/views/components/ViewLayout.tsx | 54 +++++ src/pages/views/components/ViewSection.tsx | 82 ++++++++ src/ui/MRTDataTable/MRTDataTable.tsx | 4 +- src/utils/viewHash.ts | 18 ++ 8 files changed, 327 insertions(+), 173 deletions(-) create mode 100644 src/pages/views/components/ViewLayout.tsx create mode 100644 src/pages/views/components/ViewSection.tsx create mode 100644 src/utils/viewHash.ts diff --git a/src/api/services/views.ts b/src/api/services/views.ts index b453619ad..698aa4d91 100644 --- a/src/api/services/views.ts +++ b/src/api/services/views.ts @@ -99,6 +99,36 @@ export const getViewDataById = async ( return response.json(); }; +export const getViewDataByNamespace = async ( + namespace: string, + name: string, + variables?: Record, + headers?: Record +): Promise => { + const body: { variables?: Record } = { + variables: variables + }; + + const response = await fetch(`/api/view/${namespace}/${name}`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + ...headers + }, + body: JSON.stringify(body) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error( + errorData.error || `HTTP ${response.status}: ${response.statusText}` + ); + } + + return response.json(); +}; + /** * 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..2771abdbe 100644 --- a/src/pages/audit-report/components/View/View.tsx +++ b/src/pages/audit-report/components/View/View.tsx @@ -27,6 +27,7 @@ import { import GlobalFilters from "./GlobalFilters"; import GlobalFiltersForm from "./GlobalFiltersForm"; import { usePrefixedSearchParams } from "../../../../hooks/usePrefixedSearchParams"; +import { getViewPrefix } from "../../../../utils/viewHash"; import ViewCardsDisplay from "./ViewCardsDisplay"; interface ViewProps { @@ -66,8 +67,11 @@ 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 = useMemo( + () => getViewPrefix(namespace, name), + [namespace, name] + ); const hasDataTable = columns && columns.length > 0; // Detect if card mode is available (supports both new and old cardPosition field) @@ -161,94 +165,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 }) => ( - - ))} -
+ {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 +276,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..c3d1da53b 100644 --- a/src/pages/views/components/SingleView.tsx +++ b/src/pages/views/components/SingleView.tsx @@ -1,82 +1,21 @@ import React, { 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"; 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,16 +23,16 @@ 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 }); const handleForceRefresh = async () => { @@ -170,6 +109,16 @@ const SingleView: React.FC = ({ id }) => { const { icon, title, namespace, name } = viewResult; + // Render the main view as a section as well. + // Doing this due to some CSS issues that I couldn't solve. + const mySection = { + title: "", + viewRef: { + namespace: namespace || "", + name: name + } + }; + return ( = ({ id }) => { } >
- +
+ +
+ + {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..d6e724297 --- /dev/null +++ b/src/pages/views/components/ViewLayout.tsx @@ -0,0 +1,54 @@ +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..bf99b954e --- /dev/null +++ b/src/pages/views/components/ViewSection.tsx @@ -0,0 +1,82 @@ +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 { ViewSection as Section } from "../../audit-report/types"; +import { Icon } from "../../../ui/Icons/Icon"; +import { usePrefixedSearchParams } from "../../../hooks/usePrefixedSearchParams"; +import { getViewPrefix } from "../../../utils/viewHash"; + +interface ViewSectionProps { + section: Section; +} + +const ViewSection: React.FC = ({ section }) => { + const { namespace, name } = section.viewRef; + + // Use unique prefix for this section's variables + const sectionPrefix = getViewPrefix(namespace, name); + const [sectionVarParams] = usePrefixedSearchParams(sectionPrefix, false); + const currentViewVariables = Object.fromEntries(sectionVarParams.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/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: () => ({ diff --git a/src/utils/viewHash.ts b/src/utils/viewHash.ts new file mode 100644 index 000000000..f2ebfaebf --- /dev/null +++ b/src/utils/viewHash.ts @@ -0,0 +1,18 @@ +import hash from "object-hash"; + +/** + * Generates a unique URL parameter prefix for a view based on its namespace and name. + * Uses MD5-like hashing via object-hash to create a consistent, compact prefix. + * + * @param namespace - The view namespace + * @param name - The view name + * @returns A unique prefix string, e.g., "viewvar_a1b2c3d4" + */ +export const getViewPrefix = ( + namespace: string | undefined, + name: string +): string => { + const key = `${namespace || "default"}_${name}`; + const hashValue = hash(key, { algorithm: "md5" }).substring(0, 8); + return `viewvar_${hashValue}`; +}; From 04dba368e264883b9f1cc3e6a29c02b643321798 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 26 Nov 2025 12:19:55 +0545 Subject: [PATCH 2/9] fix: view variables between sections --- src/pages/audit-report/components/View/View.tsx | 7 ++----- src/pages/views/components/ViewSection.tsx | 13 ++++++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/pages/audit-report/components/View/View.tsx b/src/pages/audit-report/components/View/View.tsx index 2771abdbe..976319a66 100644 --- a/src/pages/audit-report/components/View/View.tsx +++ b/src/pages/audit-report/components/View/View.tsx @@ -27,8 +27,8 @@ import { import GlobalFilters from "./GlobalFilters"; import GlobalFiltersForm from "./GlobalFiltersForm"; import { usePrefixedSearchParams } from "../../../../hooks/usePrefixedSearchParams"; -import { getViewPrefix } from "../../../../utils/viewHash"; import ViewCardsDisplay from "./ViewCardsDisplay"; +import { VIEW_VAR_PREFIX } from "@flanksource-ui/pages/views/components/ViewSection"; interface ViewProps { title?: string; @@ -68,10 +68,7 @@ const View: React.FC = ({ const [searchParams, setSearchParams] = useSearchParams(); // Create unique prefix for global filters (same as ViewSection uses) - const globalVarPrefix = useMemo( - () => getViewPrefix(namespace, name), - [namespace, name] - ); + const globalVarPrefix = VIEW_VAR_PREFIX; const hasDataTable = columns && columns.length > 0; // Detect if card mode is available (supports both new and old cardPosition field) diff --git a/src/pages/views/components/ViewSection.tsx b/src/pages/views/components/ViewSection.tsx index bf99b954e..a6c1bbf09 100644 --- a/src/pages/views/components/ViewSection.tsx +++ b/src/pages/views/components/ViewSection.tsx @@ -5,19 +5,22 @@ import View from "../../audit-report/components/View/View"; import { ViewSection as Section } from "../../audit-report/types"; import { Icon } from "../../../ui/Icons/Icon"; import { usePrefixedSearchParams } from "../../../hooks/usePrefixedSearchParams"; -import { getViewPrefix } from "../../../utils/viewHash"; interface ViewSectionProps { section: Section; } +// This is the prefix for all the query params that are related to the view variables. +export const VIEW_VAR_PREFIX = "viewvar"; + const ViewSection: React.FC = ({ section }) => { const { namespace, name } = section.viewRef; - // Use unique prefix for this section's variables - const sectionPrefix = getViewPrefix(namespace, name); - const [sectionVarParams] = usePrefixedSearchParams(sectionPrefix, false); - const currentViewVariables = Object.fromEntries(sectionVarParams.entries()); + // 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 { From f0d7551f142e9f65e96f60b640eeac03554654ca Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Wed, 26 Nov 2025 12:41:25 +0545 Subject: [PATCH 3/9] feat: Display templating variables once at top of page for multi-section views --- .../audit-report/components/View/View.tsx | 8 +-- src/pages/views/components/SingleView.tsx | 50 +++++++++++++++++-- src/pages/views/components/ViewSection.tsx | 7 ++- .../views/hooks/useAggregatedViewVariables.ts | 45 +++++++++++++++++ src/pages/views/utils/aggregateVariables.ts | 33 ++++++++++++ 5 files changed, 135 insertions(+), 8 deletions(-) create mode 100644 src/pages/views/hooks/useAggregatedViewVariables.ts create mode 100644 src/pages/views/utils/aggregateVariables.ts diff --git a/src/pages/audit-report/components/View/View.tsx b/src/pages/audit-report/components/View/View.tsx index 976319a66..4d14de4bc 100644 --- a/src/pages/audit-report/components/View/View.tsx +++ b/src/pages/audit-report/components/View/View.tsx @@ -44,6 +44,7 @@ interface ViewProps { }; requestFingerprint: string; currentVariables?: Record; + hideVariables?: boolean; } const View: React.FC = ({ @@ -56,7 +57,8 @@ const View: React.FC = ({ variables, card, requestFingerprint, - currentVariables + currentVariables, + hideVariables }) => { const { pageSize } = useReactTablePaginationState(); @@ -170,7 +172,7 @@ const View: React.FC = ({ )} - {variables && variables.length > 0 && ( + {!hideVariables && variables && variables.length > 0 && ( = ({ )} - {variables && variables.length > 0 && ( + {!hideVariables && variables && variables.length > 0 && (
)} diff --git a/src/pages/views/components/SingleView.tsx b/src/pages/views/components/SingleView.tsx index c3d1da53b..b9b09d8d5 100644 --- a/src/pages/views/components/SingleView.tsx +++ b/src/pages/views/components/SingleView.tsx @@ -1,10 +1,13 @@ -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 ViewSection from "./ViewSection"; +import ViewSection, { VIEW_VAR_PREFIX } 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"; interface SingleViewProps { id: string; @@ -35,6 +38,30 @@ const SingleView: React.FC = ({ id }) => { 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(); @@ -135,15 +162,30 @@ const SingleView: React.FC = ({ 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/ViewSection.tsx b/src/pages/views/components/ViewSection.tsx index a6c1bbf09..013969818 100644 --- a/src/pages/views/components/ViewSection.tsx +++ b/src/pages/views/components/ViewSection.tsx @@ -8,12 +8,16 @@ import { usePrefixedSearchParams } from "../../../hooks/usePrefixedSearchParams" interface ViewSectionProps { section: Section; + hideVariables?: boolean; } // This is the prefix for all the query params that are related to the view variables. export const VIEW_VAR_PREFIX = "viewvar"; -const ViewSection: React.FC = ({ section }) => { +const ViewSection: React.FC = ({ + section, + hideVariables +}) => { const { namespace, name } = section.viewRef; // Use prefixed search params for view variables @@ -77,6 +81,7 @@ const ViewSection: React.FC = ({ section }) => { card={sectionViewResult?.card} requestFingerprint={sectionViewResult.requestFingerprint} currentVariables={currentViewVariables} + hideVariables={hideVariables} /> ); diff --git a/src/pages/views/hooks/useAggregatedViewVariables.ts b/src/pages/views/hooks/useAggregatedViewVariables.ts new file mode 100644 index 000000000..b5ee8df2c --- /dev/null +++ b/src/pages/views/hooks/useAggregatedViewVariables.ts @@ -0,0 +1,45 @@ +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 "../components/ViewSection"; + +interface ViewRef { + namespace: string; + name: string; +} + +export function useAggregatedViewVariables(sections: ViewRef[]) { + const [viewVarParams] = usePrefixedSearchParams(VIEW_VAR_PREFIX, false); + const currentVariables = Object.fromEntries(viewVarParams.entries()); + + // Fetch all sections in parallel + const queries = useQueries({ + queries: sections.map((section) => ({ + queryKey: [ + "view-result", + section.namespace, + section.name, + currentVariables + ], + queryFn: () => + getViewDataByNamespace( + section.namespace, + section.name, + currentVariables + ), + enabled: !!section.namespace && !!section.name, + staleTime: 5 * 60 * 1000 + })) + }); + + 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..f1d854d33 --- /dev/null +++ b/src/pages/views/utils/aggregateVariables.ts @@ -0,0 +1,33 @@ +import { ViewVariable } from "../../audit-report/types"; + +/** + * Aggregates variables from multiple views, deduplicating by key. + * When same key appears multiple times, merges options arrays (union). + */ +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()); +} From 3d1f599202f634ce8a09d8878a4a01c38cc3c65a Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 1 Dec 2025 22:53:38 +0545 Subject: [PATCH 4/9] Fix refresh so sections re-fetch actual data --- src/pages/views/components/SingleView.tsx | 31 ++++++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/src/pages/views/components/SingleView.tsx b/src/pages/views/components/SingleView.tsx index b9b09d8d5..fa6e9a7f2 100644 --- a/src/pages/views/components/SingleView.tsx +++ b/src/pages/views/components/SingleView.tsx @@ -73,11 +73,34 @@ 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 }] + : []; + + await Promise.all( + sectionsToRefresh.map((section) => + queryClient.invalidateQueries({ + queryKey: ["view-result", section.namespace, section.name] + }) + ) + ); + + await Promise.all( + sectionsToRefresh.map((section) => + queryClient.invalidateQueries({ + queryKey: ["view-table", section.namespace, section.name] + }) + ) + ); }; if (isLoading && !viewResult) { From 68a0908ececd90f3a9e99b47c0be4ca07d54774f Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 1 Dec 2025 22:54:20 +0545 Subject: [PATCH 5/9] Eliminate circular dependency between view.tsx and ViewSection.tsx & Make section React keys unique across namespaces to avoid collisions * Remove or wire up unused viewHash helper to avoid dead code --- .../audit-report/components/View/View.tsx | 2 +- src/pages/views/components/SingleView.tsx | 14 +++++++++++--- src/pages/views/components/ViewSection.tsx | 6 ++---- src/pages/views/constants.ts | 2 ++ .../views/hooks/useAggregatedViewVariables.ts | 2 +- src/utils/viewHash.ts | 18 ------------------ 6 files changed, 17 insertions(+), 27 deletions(-) create mode 100644 src/pages/views/constants.ts delete mode 100644 src/utils/viewHash.ts diff --git a/src/pages/audit-report/components/View/View.tsx b/src/pages/audit-report/components/View/View.tsx index 4d14de4bc..a78196e57 100644 --- a/src/pages/audit-report/components/View/View.tsx +++ b/src/pages/audit-report/components/View/View.tsx @@ -28,7 +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/components/ViewSection"; +import { VIEW_VAR_PREFIX } from "@flanksource-ui/pages/views/constants"; interface ViewProps { title?: string; diff --git a/src/pages/views/components/SingleView.tsx b/src/pages/views/components/SingleView.tsx index fa6e9a7f2..ddc1314ec 100644 --- a/src/pages/views/components/SingleView.tsx +++ b/src/pages/views/components/SingleView.tsx @@ -1,13 +1,14 @@ import React, { useMemo, useRef } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { getViewDataById } from "../../../api/services/views"; -import ViewSection, { VIEW_VAR_PREFIX } from "./ViewSection"; +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"; interface SingleViewProps { id: string; @@ -201,13 +202,20 @@ const SingleView: React.FC = ({ id }) => { )}
- +
{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 013969818..4c3d8067e 100644 --- a/src/pages/views/components/ViewSection.tsx +++ b/src/pages/views/components/ViewSection.tsx @@ -2,18 +2,16 @@ 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 { ViewSection as Section } from "../../audit-report/types"; 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; } -// This is the prefix for all the query params that are related to the view variables. -export const VIEW_VAR_PREFIX = "viewvar"; - const ViewSection: React.FC = ({ section, hideVariables 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 index b5ee8df2c..55ee57f2e 100644 --- a/src/pages/views/hooks/useAggregatedViewVariables.ts +++ b/src/pages/views/hooks/useAggregatedViewVariables.ts @@ -2,7 +2,7 @@ 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 "../components/ViewSection"; +import { VIEW_VAR_PREFIX } from "../constants"; interface ViewRef { namespace: string; diff --git a/src/utils/viewHash.ts b/src/utils/viewHash.ts deleted file mode 100644 index f2ebfaebf..000000000 --- a/src/utils/viewHash.ts +++ /dev/null @@ -1,18 +0,0 @@ -import hash from "object-hash"; - -/** - * Generates a unique URL parameter prefix for a view based on its namespace and name. - * Uses MD5-like hashing via object-hash to create a consistent, compact prefix. - * - * @param namespace - The view namespace - * @param name - The view name - * @returns A unique prefix string, e.g., "viewvar_a1b2c3d4" - */ -export const getViewPrefix = ( - namespace: string | undefined, - name: string -): string => { - const key = `${namespace || "default"}_${name}`; - const hashValue = hash(key, { algorithm: "md5" }).substring(0, 8); - return `viewvar_${hashValue}`; -}; From fea17141beb7282e3e853eac33835f07788b3d26 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 1 Dec 2025 22:58:17 +0545 Subject: [PATCH 6/9] fix: Stop global filter bar flicker/re-fetch on every change (adjust useAggregatedViewVariables caching/query keys) --- src/pages/views/components/SingleView.tsx | 8 ++++++++ .../views/hooks/useAggregatedViewVariables.ts | 17 ++++------------- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/pages/views/components/SingleView.tsx b/src/pages/views/components/SingleView.tsx index ddc1314ec..504d84f77 100644 --- a/src/pages/views/components/SingleView.tsx +++ b/src/pages/views/components/SingleView.tsx @@ -102,6 +102,14 @@ const SingleView: React.FC = ({ id }) => { }) ) ); + + await Promise.all( + sectionsToRefresh.map((section) => + queryClient.invalidateQueries({ + queryKey: ["view-variables", section.namespace, section.name] + }) + ) + ); }; if (isLoading && !viewResult) { diff --git a/src/pages/views/hooks/useAggregatedViewVariables.ts b/src/pages/views/hooks/useAggregatedViewVariables.ts index 55ee57f2e..30c2ec441 100644 --- a/src/pages/views/hooks/useAggregatedViewVariables.ts +++ b/src/pages/views/hooks/useAggregatedViewVariables.ts @@ -16,20 +16,11 @@ export function useAggregatedViewVariables(sections: ViewRef[]) { // Fetch all sections in parallel const queries = useQueries({ queries: sections.map((section) => ({ - queryKey: [ - "view-result", - section.namespace, - section.name, - currentVariables - ], - queryFn: () => - getViewDataByNamespace( - section.namespace, - section.name, - currentVariables - ), + queryKey: ["view-variables", section.namespace, section.name], + queryFn: () => getViewDataByNamespace(section.namespace, section.name), enabled: !!section.namespace && !!section.name, - staleTime: 5 * 60 * 1000 + staleTime: 5 * 60 * 1000, + keepPreviousData: true })) }); From 52cce0243349e5af66722ba2c08f9c8b2bf7ef28 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 1 Dec 2025 23:08:22 +0545 Subject: [PATCH 7/9] fix: updating the dependent templating variable --- .../views/hooks/useAggregatedViewVariables.ts | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/pages/views/hooks/useAggregatedViewVariables.ts b/src/pages/views/hooks/useAggregatedViewVariables.ts index 30c2ec441..661912f89 100644 --- a/src/pages/views/hooks/useAggregatedViewVariables.ts +++ b/src/pages/views/hooks/useAggregatedViewVariables.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react"; import { useQueries } from "@tanstack/react-query"; import { getViewDataByNamespace } from "../../../api/services/views"; import { aggregateVariables } from "../utils/aggregateVariables"; @@ -11,13 +12,33 @@ interface ViewRef { export function useAggregatedViewVariables(sections: ViewRef[]) { const [viewVarParams] = usePrefixedSearchParams(VIEW_VAR_PREFIX, false); - const currentVariables = Object.fromEntries(viewVarParams.entries()); + 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], - queryFn: () => getViewDataByNamespace(section.namespace, section.name), + 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 From f7dda47a1f4c55a73731e505e92d1e0e529b7c74 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Mon, 1 Dec 2025 23:31:47 +0545 Subject: [PATCH 8/9] Fix view metadata refresh and add missing type import --- src/pages/views/components/SingleView.tsx | 3 +++ src/pages/views/components/ViewLayout.tsx | 1 + 2 files changed, 4 insertions(+) diff --git a/src/pages/views/components/SingleView.tsx b/src/pages/views/components/SingleView.tsx index 504d84f77..423a62078 100644 --- a/src/pages/views/components/SingleView.tsx +++ b/src/pages/views/components/SingleView.tsx @@ -87,6 +87,9 @@ const SingleView: React.FC = ({ id }) => { ? [{ 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.map((section) => queryClient.invalidateQueries({ diff --git a/src/pages/views/components/ViewLayout.tsx b/src/pages/views/components/ViewLayout.tsx index d6e724297..803dbcea8 100644 --- a/src/pages/views/components/ViewLayout.tsx +++ b/src/pages/views/components/ViewLayout.tsx @@ -1,3 +1,4 @@ +import type React from "react"; import { Head } from "../../../ui/Head"; import { Icon } from "../../../ui/Icons/Icon"; import { SearchLayout } from "../../../ui/Layout/SearchLayout"; From 36561398426c50105ee475951effdb76b46c0131 Mon Sep 17 00:00:00 2001 From: Aditya Thebe Date: Tue, 2 Dec 2025 00:55:19 +0545 Subject: [PATCH 9/9] Refactor view API to use shared fetch helper and add URL encoding --- src/api/services/views.ts | 47 +++++++++---------- src/pages/views/components/SingleView.tsx | 33 +++++-------- .../views/hooks/useAggregatedViewVariables.ts | 6 +-- src/pages/views/utils/aggregateVariables.ts | 3 +- 4 files changed, 35 insertions(+), 54 deletions(-) diff --git a/src/api/services/views.ts b/src/api/services/views.ts index 698aa4d91..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,34 +99,29 @@ 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 => { - const body: { variables?: Record } = { - variables: variables - }; - - const response = await fetch(`/api/view/${namespace}/${name}`, { - method: "POST", - credentials: "include", - headers: { - "Content-Type": "application/json", - ...headers - }, - body: JSON.stringify(body) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error( - errorData.error || `HTTP ${response.status}: ${response.statusText}` - ); - } - - return response.json(); + return fetchViewData( + `/api/view/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`, + variables, + headers + ); }; /** diff --git a/src/pages/views/components/SingleView.tsx b/src/pages/views/components/SingleView.tsx index 423a62078..c6dce2fb1 100644 --- a/src/pages/views/components/SingleView.tsx +++ b/src/pages/views/components/SingleView.tsx @@ -9,6 +9,7 @@ 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; @@ -41,13 +42,11 @@ const SingleView: React.FC = ({ id }) => { // Collect all section refs (main view + additional sections) // Must be called before early returns to satisfy React hooks rules - const allSectionRefs = useMemo(() => { + const allSectionRefs = useMemo(() => { if (!viewResult?.namespace || !viewResult?.name) { return []; } - const refs = [ - { namespace: viewResult.namespace || "", name: viewResult.name } - ]; + const refs = [{ namespace: viewResult.namespace, name: viewResult.name }]; if (viewResult?.sections) { viewResult.sections.forEach((section) => { refs.push({ @@ -91,27 +90,17 @@ const SingleView: React.FC = ({ id }) => { await queryClient.invalidateQueries({ queryKey: ["view-result", id] }); await Promise.all( - sectionsToRefresh.map((section) => + sectionsToRefresh.flatMap((section) => [ queryClient.invalidateQueries({ queryKey: ["view-result", section.namespace, section.name] - }) - ) - ); - - await Promise.all( - sectionsToRefresh.map((section) => + }), queryClient.invalidateQueries({ queryKey: ["view-table", section.namespace, section.name] - }) - ) - ); - - await Promise.all( - sectionsToRefresh.map((section) => + }), queryClient.invalidateQueries({ queryKey: ["view-variables", section.namespace, section.name] }) - ) + ]) ); }; @@ -171,9 +160,9 @@ const SingleView: React.FC = ({ id }) => { const { icon, title, namespace, name } = viewResult; - // Render the main view as a section as well. - // Doing this due to some CSS issues that I couldn't solve. - const mySection = { + // 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 || "", @@ -215,7 +204,7 @@ const SingleView: React.FC = ({ id }) => {
diff --git a/src/pages/views/hooks/useAggregatedViewVariables.ts b/src/pages/views/hooks/useAggregatedViewVariables.ts index 661912f89..16fa1f394 100644 --- a/src/pages/views/hooks/useAggregatedViewVariables.ts +++ b/src/pages/views/hooks/useAggregatedViewVariables.ts @@ -4,11 +4,7 @@ import { getViewDataByNamespace } from "../../../api/services/views"; import { aggregateVariables } from "../utils/aggregateVariables"; import { usePrefixedSearchParams } from "../../../hooks/usePrefixedSearchParams"; import { VIEW_VAR_PREFIX } from "../constants"; - -interface ViewRef { - namespace: string; - name: string; -} +import type { ViewRef } from "../../audit-report/types"; export function useAggregatedViewVariables(sections: ViewRef[]) { const [viewVarParams] = usePrefixedSearchParams(VIEW_VAR_PREFIX, false); diff --git a/src/pages/views/utils/aggregateVariables.ts b/src/pages/views/utils/aggregateVariables.ts index f1d854d33..429d5a610 100644 --- a/src/pages/views/utils/aggregateVariables.ts +++ b/src/pages/views/utils/aggregateVariables.ts @@ -2,7 +2,8 @@ import { ViewVariable } from "../../audit-report/types"; /** * Aggregates variables from multiple views, deduplicating by key. - * When same key appears multiple times, merges options arrays (union). + * 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)[]