Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 67 additions & 24 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ import {
import { Canary, Icon } from "./components";
import AuthProviderWrapper from "./components/Authentication/AuthProviderWrapper";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { HomepageRedirect } from "./components/HomepageRedirect";
import {
DASHBOARD_VIEW_PROPERTY,
FALLBACK_VIEW_NAME,
UUID_REGEX
} from "./components/dashboardViewConstants";
import { withAuthorizationAccessCheck } from "./components/Permissions/AuthorizationAccessCheck";
import { SchemaResource } from "./components/SchemaResourcePage/SchemaResource";
import {
Expand All @@ -46,7 +52,6 @@ import { features } from "./services/permissions/features";
import { getViewsForSidebar, ViewSummary } from "./api/services/views";
import { Head } from "./ui/Head";
import { LogsIcon } from "./ui/Icons/LogsIcon";
import { TopologyIcon } from "./ui/Icons/TopologyIcon";
import { SidebarLayout } from "./ui/Layout/SidebarLayout";
import FullPageSkeletonLoader from "./ui/SkeletonLoader/FullPageSkeletonLoader";
import { ToasterWithCloseButton } from "./ui/ToasterWithCloseButton";
Expand Down Expand Up @@ -301,13 +306,6 @@ export type NavigationItems = {
}[];

const navigation: NavigationItems = [
{
name: "Dashboard",
href: "/topology",
icon: TopologyIcon,
featureName: features.topology,
resourceName: tables.database
},
{
name: "Health",
href: "/health",
Expand Down Expand Up @@ -506,7 +504,7 @@ export function IncidentManagerRoutes({ sidebar }: { sidebar: ReactNode }) {

return (
<Routes>
<Route path="" element={<Navigate to="/topology" />} />
<Route path="" element={<HomepageRedirect />} />

<Route
path="/view/topology/:id"
Expand Down Expand Up @@ -1083,30 +1081,75 @@ export function CanaryCheckerApp() {
);
}

function findDashboardView(
views: ViewSummary[],
dashboardViewValue?: string
): ViewSummary | undefined {
if (!dashboardViewValue) {
return views.find((v) => v.name === FALLBACK_VIEW_NAME);
}

if (UUID_REGEX.test(dashboardViewValue)) {
return views.find((v) => v.id === dashboardViewValue);
}

if (dashboardViewValue.includes("/")) {
const [namespace, name] = dashboardViewValue.split("/", 2);
return views.find((v) => v.namespace === namespace && v.name === name);
}

return views.find((v) => v.name === dashboardViewValue);
}
Comment on lines +1084 to +1102
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's find getViewsForSidebar definition
ast-grep --pattern 'export const getViewsForSidebar = $_'

Repository: flanksource/flanksource-ui

Length of output: 599


🏁 Script executed:

# Also search for getViewsForSidebar more broadly
rg 'getViewsForSidebar' -A 5 -B 2

Repository: flanksource/flanksource-ui

Length of output: 1472


🏁 Script executed:

# Find where findDashboardView is called
rg 'findDashboardView' -B 3 -A 3

Repository: flanksource/flanksource-ui

Length of output: 564


🏁 Script executed:

# Find HomepageRedirect and getViewIdByName
rg 'HomepageRedirect|getViewIdByName' -B 3 -A 5

Repository: flanksource/flanksource-ui

Length of output: 10534


🏁 Script executed:

rg 'export const getViewIdByNamespaceAndName' -A 8 src/api/services/views.ts

Repository: flanksource/flanksource-ui

Length of output: 381


Dashboard view may not appear in sidebar if sidebar flag is false.

findDashboardView searches the views array returned by getViewsForSidebar, which filters on sidebar=eq.true. If the configured dashboard view has sidebar=false, it won't be found here, so no dashboard nav item will be added to the sidebar — even though HomepageRedirect will still redirect to it (since it queries all views via getViewIdByName or getViewIdByNamespaceAndName, both without the sidebar filter).

If the intent is that the dashboard view always appears in the sidebar nav regardless of its sidebar flag, this needs a separate fetch or an adjusted query. If the expectation is that the dashboard view must have sidebar=true, this is fine but worth documenting.

🤖 Prompt for AI Agents
In `@src/App.tsx` around lines 1084 - 1102, findDashboardView currently looks only
in the views returned from getViewsForSidebar (which filters sidebar=eq.true) so
a configured dashboard with sidebar=false will not be found and thus not added
to the sidebar even though HomepageRedirect still redirects to it; update the
logic in findDashboardView (or the code that calls it) to, when the initial
search in the getViewsForSidebar results returns undefined, perform a secondary
fetch for the specific dashboard view (using the existing helpers
getViewIdByName or getViewIdByNamespaceAndName or a direct getViewById call) and
return that result so the dashboard nav item is added regardless of the sidebar
flag, or alternatively adjust the initial fetch to include sidebar values for
the configured dashboard lookup.


function useDynamicNavigation() {
const { featureFlags } = useFeatureFlagsContext();
const { data: views = [] } = useQuery<ViewSummary[]>({
queryKey: ["views-sidebar"],
queryFn: getViewsForSidebar,
staleTime: 5 * 60 * 1000
});

const dashboardViewValue = featureFlags.find(
(f) => f.name === DASHBOARD_VIEW_PROPERTY
)?.value;

return useMemo(() => {
const viewsNavigationItems = views.map((view) => ({
name: view.title || view.name,
href: `/views/${view.id}`,
icon: ({ className }: { className?: string }) => (
<Icon
name={view.icon || "workflow"}
className={`${className} text-white`}
/>
),
featureName: features.views,
resourceName: tables.views
}));
const dashboardView = findDashboardView(views, dashboardViewValue);

const viewsNavigationItems = views
.filter((view) => view.id !== dashboardView?.id)
.map((view) => ({
name: view.title || view.name,
href: `/views/${view.id}`,
icon: ({ className }: { className?: string }) => (
<Icon
name={view.icon || "workflow"}
className={`${className} text-white`}
/>
),
featureName: features.views,
resourceName: tables.views
}));

const dashboardNavItem = dashboardView
? [
{
name: dashboardView.title || dashboardView.name,
href: `/views/${dashboardView.id}`,
icon: ({ className }: { className?: string }) => (
<Icon
name={dashboardView.icon || "workflow"}
className={`${className} text-white`}
/>
),
featureName: features.views,
resourceName: tables.views
}
]
: [];

const navigationWithViews = [...navigation, ...viewsNavigationItems];
return navigationWithViews;
}, [views]);
return [...dashboardNavItem, ...navigation, ...viewsNavigationItems];
}, [views, dashboardViewValue]);
}

function SidebarWrapper() {
Expand Down
61 changes: 61 additions & 0 deletions src/components/HomepageRedirect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// ABOUTME: Resolves the homepage destination based on a configured view property,
// ABOUTME: a well-known view name, or falls back to the health page.

import { useQuery } from "@tanstack/react-query";
import { Navigate } from "react-router-dom";
import {
getViewIdByName,
getViewIdByNamespaceAndName
} from "../api/services/views";
import { useFeatureFlagsContext } from "../context/FeatureFlagsContext";
import FullPageSkeletonLoader from "../ui/SkeletonLoader/FullPageSkeletonLoader";

import {
DASHBOARD_VIEW_PROPERTY,
FALLBACK_VIEW_NAME,
UUID_REGEX
} from "./dashboardViewConstants";

async function resolveViewId(value: string): Promise<string | undefined> {
if (value.includes("/")) {
const [namespace, name] = value.split("/", 2);
return getViewIdByNamespaceAndName(namespace, name);
}
return getViewIdByName(value);
}

export function HomepageRedirect() {
const { featureFlags } = useFeatureFlagsContext();

const dashboardViewValue = featureFlags.find(
(f) => f.name === DASHBOARD_VIEW_PROPERTY
)?.value;

const isUUID = dashboardViewValue && UUID_REGEX.test(dashboardViewValue);

const { data: redirectPath, isLoading } = useQuery({
queryKey: ["homepage-redirect", dashboardViewValue],
queryFn: async () => {
if (dashboardViewValue) {
const viewId = await resolveViewId(dashboardViewValue);
if (viewId) return `/views/${viewId}`;
return "/health";
}

const viewId = await getViewIdByName(FALLBACK_VIEW_NAME);
if (viewId) return `/views/${viewId}`;
return "/health";
},
enabled: !isUUID
});

if (isUUID) {
return <Navigate to={`/views/${dashboardViewValue}`} replace />;
}

if (isLoading || !redirectPath) {
return <FullPageSkeletonLoader />;
}

return <Navigate to={redirectPath} replace />;
}
169 changes: 169 additions & 0 deletions src/components/__tests__/HomepageRedirect.unit.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// ABOUTME: Tests for HomepageRedirect component that resolves the homepage
// ABOUTME: destination based on properties and view lookups.

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { HomepageRedirect } from "../HomepageRedirect";
import { FeatureFlagsContext } from "../../context/FeatureFlagsContext";
import type { FeatureFlagsState } from "../../context/FeatureFlagsContext";
import type { FeatureFlag } from "../../services/permissions/permissionsService";
import { DASHBOARD_VIEW_PROPERTY } from "../dashboardViewConstants";

import {
getViewIdByName,
getViewIdByNamespaceAndName
} from "../../api/services/views";

jest.mock("../../api/services/views", () => ({
getViewIdByName: jest.fn(),
getViewIdByNamespaceAndName: jest.fn()
}));

const mockedGetViewIdByName = getViewIdByName as jest.MockedFunction<
typeof getViewIdByName
>;
const mockedGetViewIdByNamespaceAndName =
getViewIdByNamespaceAndName as jest.MockedFunction<
typeof getViewIdByNamespaceAndName
>;

function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false }
}
});
}

function buildFeatureFlagsState(
featureFlags: FeatureFlag[] = []
): FeatureFlagsState {
return {
featureFlags,
featureFlagsLoaded: true,
refreshFeatureFlags: () => {},
isFeatureDisabled: () => false
};
}

function renderWithProviders(featureFlags: FeatureFlag[] = []) {
const queryClient = createQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<FeatureFlagsContext.Provider
value={buildFeatureFlagsState(featureFlags)}
>
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route path="/" element={<HomepageRedirect />} />
<Route
path="/health"
element={<div data-testid="health-page">Health</div>}
/>
<Route
path="/views/:id"
element={<div data-testid="view-page">View</div>}
/>
</Routes>
</MemoryRouter>
</FeatureFlagsContext.Provider>
</QueryClientProvider>
);
}

function dashboardViewFlag(value: string): FeatureFlag {
return {
name: DASHBOARD_VIEW_PROPERTY,
value,
description: "",
source: "",
type: ""
};
}

afterEach(() => {
jest.clearAllMocks();
});

describe("HomepageRedirect", () => {
it("redirects to /views/{uuid} when property is a UUID", async () => {
const uuid = "550e8400-e29b-41d4-a716-446655440000";
renderWithProviders([dashboardViewFlag(uuid)]);

await waitFor(() => {
expect(screen.getByTestId("view-page")).toBeInTheDocument();
});

expect(mockedGetViewIdByName).not.toHaveBeenCalled();
expect(mockedGetViewIdByNamespaceAndName).not.toHaveBeenCalled();
});

it("looks up by namespace/name when property contains a slash", async () => {
const viewId = "aaa-bbb-ccc";
mockedGetViewIdByNamespaceAndName.mockResolvedValue(viewId);

renderWithProviders([dashboardViewFlag("my-namespace/my-view")]);

await waitFor(() => {
expect(screen.getByTestId("view-page")).toBeInTheDocument();
});

expect(mockedGetViewIdByNamespaceAndName).toHaveBeenCalledWith(
"my-namespace",
"my-view"
);
});

it("looks up by name when property is a plain string", async () => {
const viewId = "ddd-eee-fff";
mockedGetViewIdByName.mockResolvedValue(viewId);

renderWithProviders([dashboardViewFlag("my-dashboard")]);

await waitFor(() => {
expect(screen.getByTestId("view-page")).toBeInTheDocument();
});

expect(mockedGetViewIdByName).toHaveBeenCalledWith("my-dashboard");
});

it("falls back to mission-control-dashboard view when no property is set", async () => {
const viewId = "ggg-hhh-iii";
mockedGetViewIdByName.mockResolvedValue(viewId);

renderWithProviders([]);

await waitFor(() => {
expect(screen.getByTestId("view-page")).toBeInTheDocument();
});

expect(mockedGetViewIdByName).toHaveBeenCalledWith(
"mission-control-dashboard"
);
});

it("falls back to /health when no property and no mission-control-dashboard view", async () => {
mockedGetViewIdByName.mockResolvedValue(undefined);

renderWithProviders([]);

await waitFor(() => {
expect(screen.getByTestId("health-page")).toBeInTheDocument();
});

expect(mockedGetViewIdByName).toHaveBeenCalledWith(
"mission-control-dashboard"
);
});

it("falls back to /health when property name lookup fails", async () => {
mockedGetViewIdByName.mockResolvedValue(undefined);

renderWithProviders([dashboardViewFlag("nonexistent-view")]);

await waitFor(() => {
expect(screen.getByTestId("health-page")).toBeInTheDocument();
});
});
});
9 changes: 9 additions & 0 deletions src/components/dashboardViewConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// ABOUTME: Shared constants for resolving the dashboard view from properties.
// ABOUTME: Used by HomepageRedirect and the sidebar navigation.

export const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

export const FALLBACK_VIEW_NAME = "mission-control-dashboard";

export const DASHBOARD_VIEW_PROPERTY = "defaults.dashboard_view";
2 changes: 1 addition & 1 deletion src/context/FeatureFlagsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const initialState: FeatureFlagsState = {
isFeatureDisabled: (_) => false
};

const FeatureFlagsContext = createContext(initialState);
export const FeatureFlagsContext = createContext(initialState);

export const FeatureFlagsContextProvider = ({
children
Expand Down
Loading