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
+ };
+}