This is an automated email from the ASF dual-hosted git repository.

mgubaidullin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-karavan.git

commit 467fd594d309a60bab55346bb3e421954f9bc796
Author: Marat Gubaidullin <[email protected]>
AuthorDate: Mon Aug 24 17:58:13 2026 -0400

    Karavan-app UI Page Compass
---
 karavan-app/src/main/webui/src/ui/compass/App.css  |  25 +++
 karavan-app/src/main/webui/src/ui/compass/App.tsx  | 117 +++++++++++++
 .../src/main/webui/src/ui/compass/AppCompass.css   |  42 +++++
 .../src/main/webui/src/ui/compass/AppCompass.tsx   |  67 +++++++
 .../src/main/webui/src/ui/compass/AppDock.css      |  44 +++++
 .../src/main/webui/src/ui/compass/AppDock.tsx      | 192 +++++++++++++++++++++
 .../src/main/webui/src/ui/compass/AppFooter.css    |   4 +
 .../src/main/webui/src/ui/compass/AppFooter.tsx    |  43 +++++
 .../src/main/webui/src/ui/compass/AppMain.css      |   6 +
 .../src/main/webui/src/ui/compass/AppMain.tsx      |  26 +++
 .../main/webui/src/ui/compass/AppNavigation.tsx    |  36 ++++
 .../main/webui/src/ui/compass/ReadinessPanel.tsx   |  74 ++++++++
 .../webui/src/ui/compass/navigation/MainRoutes.tsx |  89 ++++++++++
 .../src/ui/compass/navigation/NavigationMenu.tsx   |  52 ++++++
 .../ui/compass/navigation/NotAuthorizedPage.tsx    |  26 +++
 .../src/ui/compass/navigation/PageFallback.tsx     |  13 ++
 .../src/ui/compass/navigation/PlatformLogo.tsx     |  32 ++++
 .../src/ui/compass/navigation/ProtectedRoute.tsx   |  32 ++++
 .../main/webui/src/ui/compass/navigation/Routes.ts |  15 ++
 .../webui/src/ui/compass/theme/DarkModeToggle.tsx  |  28 +++
 .../webui/src/ui/compass/theme/ThemeContext.tsx    |  50 ++++++
 .../main/webui/src/ui/compass/useCompassStore.ts   |  59 +++++++
 .../src/main/webui/src/ui/compass/useConfig.tsx    |  29 ++++
 .../src/main/webui/src/ui/compass/useMainHook.tsx  | 153 ++++++++++++++++
 24 files changed, 1254 insertions(+)

diff --git a/karavan-app/src/main/webui/src/ui/compass/App.css 
b/karavan-app/src/main/webui/src/ui/compass/App.css
new file mode 100644
index 00000000..d246edeb
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/App.css
@@ -0,0 +1,25 @@
+.root-main {
+    display: flex;
+    flex-direction: row;
+    height: 100%;
+}
+
+.karavan .loading-page {
+    width: 100%;
+}
+.karavan .loading-page .spinner {
+    position: absolute;
+}
+.karavan .loading-page .logo-placeholder {
+    position: absolute;
+    height: 100px;
+}
+
+.karavan .loading-page .logo {
+    height: 100px;
+}
+
+:root .pf-v6-c-compass__nav-main {
+    padding-inline-start: var(--pf-t--global--spacer--md);
+    padding-inline-end: var(--pf-t--global--spacer--md);
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/compass/App.tsx 
b/karavan-app/src/main/webui/src/ui/compass/App.tsx
new file mode 100644
index 00000000..a9fef72a
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/App.tsx
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React, {useContext, useEffect, useRef} from "react";
+import {useMainHook} from "@compass/useMainHook";
+import {Notification} from "@designer/utils/Notification";
+import {NotificationApi} from "@api/NotificationApi";
+import {AuthContext} from "@api/auth/AuthProvider";
+import {AuthApi, getCurrentUser} from "@api/auth/AuthApi";
+import {PLATFORM_DEVELOPER} from "@models/AccessModels";
+import {ReadinessPanel} from "@compass/ReadinessPanel";
+import {useReadinessStore} from "@stores/ReadinessStore";
+import {useNavigate} from "react-router-dom";
+import {useUIStore} from "@stores/useUIStore";
+import {ErrorEventBus} from "@bus/ErrorEventBus";
+import {ROUTES} from "@compass/navigation/Routes";
+import {LoginPage} from "@login/LoginPage";
+import AppCompass from "@compass/AppCompass";
+import '@compass/App.css';
+import {useGlobalShortcuts} from "@command-palette/CommandEventBus";
+
+
+export function App() {
+
+    const {readiness} = useReadinessStore();
+    const {fetchBrand, customLogo} = useUIStore();
+    const controllerRef = useRef(new AbortController());
+    const {getData, showApplication} = useMainHook();
+    const show = showApplication();
+    const { user, loading, authType } = useContext(AuthContext);
+    const navigate = useNavigate();
+    useGlobalShortcuts();
+
+    useEffect(() => {
+        const interval = setInterval(() => resetNotification(), 60000);
+        const sub = ErrorEventBus.onApiError()?.subscribe(err => {
+            console.log("ApiError", err?.config?.url, err)
+            if (err?.response?.status === 401 && AuthApi.authType === 
'session') {
+                navigate(ROUTES.LOGIN);
+                window.location.reload();
+            }
+        });
+        return () => {
+            clearInterval(interval);
+            sub?.unsubscribe();
+        };
+    }, []);
+
+    useEffect(() => {
+        if (showMain()) {
+            getData();
+            resetNotification();
+        }
+    }, [readiness, user]);
+
+    useEffect(() => {
+        if (user && customLogo === undefined) {
+            fetchBrand();
+        }
+    }, [readiness, user]);
+
+    function resetNotification() {
+        try {
+            controllerRef.current.abort()
+            const controller = new AbortController();
+            controllerRef.current = controller;
+            NotificationApi.notification(controller);
+        } catch (e) {
+            console.error(e);
+        }
+    }
+
+    function showMain() {
+        return AuthApi.authType !== undefined && readiness?.status === true;
+    }
+
+    function isViewer(){
+        return getCurrentUser()?.roles?.includes(PLATFORM_DEVELOPER);
+    }
+
+    // The username/password LoginPage belongs to the 'session' auth type only.
+    // In 'oidc' mode SsoApi redirects the browser to Keycloak (onLoad: 
'login-required'),
+    // so rendering it while that redirect is in flight only produces a flash.
+    function showLoginPage() {
+        return !user && !loading && authType === 'session';
+    }
+
+    if (show) {
+        return (
+            <>
+                <ReadinessPanel/>
+                {user && <AppCompass/>}
+                {showLoginPage() && <LoginPage/>}
+                <Notification/>
+            </>
+        )
+    } else {
+        return (
+            <div className={isViewer() ? "viewer-group root-main karavan" : 
"root-main karavan"}>
+                {<ReadinessPanel/>}
+            </div>
+        )
+    }
+}
diff --git a/karavan-app/src/main/webui/src/ui/compass/AppCompass.css 
b/karavan-app/src/main/webui/src/ui/compass/AppCompass.css
new file mode 100644
index 00000000..6935f772
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/AppCompass.css
@@ -0,0 +1,42 @@
+.pf-v6-c-compass.pf-m-docked .pf-v6-c-compass__container {
+    grid-template-areas: "dock main";
+    grid-template-rows: auto;
+    grid-template-columns: auto 1fr;
+    row-gap: var(--pf-v6-c-compass__main--RowGap);
+    align-items: stretch;
+    padding: 0;
+}
+
+:root {
+    .pf-v6-c-compass__dock .pf-v6-c-nav__item::before {
+        background-color: var(--platform-color);
+    }
+    .pf-v6-c-compass__nav .pf-v6-c-tabs.pf-m-animate-current:not(.pf-m-box) 
.pf-v6-c-tabs__list::after {
+        border: 1px solid var(--platform-color);
+    }
+    .pf-v6-c-compass__logo .pf-v6-c-tabs.pf-m-animate-current:not(.pf-m-box) 
.pf-v6-c-tabs__list::after {
+        border: 1px solid var(--platform-color);
+    }
+    .pf-v6-c-tabs.pf-m-animate-current:not(.pf-m-box) 
.pf-v6-c-tabs__list::after {
+        border: 1px solid var(--platform-color);
+    }
+}
+
+.pf-v6-c-compass.pf-m-docked .pf-v6-c-compass__main {
+    padding: var(--pf-t--global--spacer--md);
+    gap: var(--pf-t--global--spacer--100);
+}
+
+.pf-v6-c-compass .pf-topology-content {
+
+}
+
+.pf-v6-c-compass .infra-icon-k8s,
+.pf-v6-c-compass .infra-icon-docker {
+    fill: #4bb9ec;
+}
+
+
+
+
+
diff --git a/karavan-app/src/main/webui/src/ui/compass/AppCompass.tsx 
b/karavan-app/src/main/webui/src/ui/compass/AppCompass.tsx
new file mode 100644
index 00000000..f9747da7
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/AppCompass.tsx
@@ -0,0 +1,67 @@
+import React, {lazy, Suspense, useEffect, useMemo} from 'react';
+import {Compass, Drawer, DrawerContent, DrawerContentBody} from 
'@patternfly/react-core';
+import {AppDock} from "./AppDock";
+import "./AppCompass.css"
+import {useCompassStore} from "./useCompassStore";
+import {shallow} from "zustand/shallow";
+import {AppMain} from "@compass/AppMain";
+import {ErrorBoundaryWrapper} from "@shared/ui/ErrorBoundaryWrapper";
+import {useProjectPageStore} from "@page-project/ProjectPageStore";
+import {useDashboardStore} from "@stores/DashboardStore";
+import {useFilesStore} from "@stores/ProjectStore";
+import {useCommandPaletteStore} from "@command-palette/useCommandPaletteStore";
+
+// The palette embeds a Monaco editor, so it is only fetched when actually 
opened.
+const CommandPaletteModal = lazy(() => 
import("@command-palette/CommandPaletteModal").then(m => ({default: 
m.CommandPaletteModal})));
+
+const AppCompass: React.FunctionComponent = () => {
+
+    const [
+        isDockExpanded,
+        isDockTextExpanded,
+        isDrawerExpanded,
+        drawerPanelContent,
+        setIsDrawerExpanded
+    ] = useCompassStore((s) => [
+        s.isDockExpanded,
+        s.isDockTextExpanded,
+        s.isDrawerExpanded,
+        s.drawerPanel,
+        s.setIsDrawerExpanded
+    ], shallow);
+
+    const showPalette = useCommandPaletteStore((s) => s.showPalette)
+
+    const showDashboardSideBar = useDashboardStore(s => s.showSideBar, 
shallow);
+    const showProjectSideBar = useProjectPageStore(s => s.showSideBar, 
shallow);
+    const showFilesSidebar = useFilesStore(s => s.showSideBar, shallow);
+
+    useEffect(() => setIsDrawerExpanded(showDashboardSideBar !== null), 
[showDashboardSideBar]);
+    useEffect(() => setIsDrawerExpanded(showProjectSideBar !== null), 
[showProjectSideBar]);
+    useEffect(() => setIsDrawerExpanded(showFilesSidebar !== null), 
[showFilesSidebar]);
+
+    const memoizedDock = useMemo(() => <AppDock/>, []);
+    const memoizedMain = useMemo(() => <AppMain/>, []);
+
+    return (
+        <Drawer isExpanded={isDrawerExpanded} position="end" isPill 
onExpand={_ => {}}>
+              <DrawerContent panelContent={drawerPanelContent}>
+                  <DrawerContentBody>
+                    <ErrorBoundaryWrapper onError={error => 
console.error(error)}>
+                        <>
+                            <Compass
+                                className={"karavan"}
+                                dock={memoizedDock}
+                                isDockExpanded={isDockExpanded}
+                                isDockTextExpanded={isDockTextExpanded}
+                                main={memoizedMain}
+                            />
+                            {showPalette && <Suspense 
fallback={null}><CommandPaletteModal/></Suspense>}
+                        </>
+                    </ErrorBoundaryWrapper>
+                  </DrawerContentBody>
+              </DrawerContent>
+          </Drawer>
+    );
+};
+export default AppCompass
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/compass/AppDock.css 
b/karavan-app/src/main/webui/src/ui/compass/AppDock.css
new file mode 100644
index 00000000..b357628c
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/AppDock.css
@@ -0,0 +1,44 @@
+.karavan .pf-v6-c-compass__dock .pf-v6-c-masthead {
+    row-gap: 8px;
+}
+
+.karavan .pf-v6-c-compass__dock .light-theme-dock {
+    background-color: var(--pf-t--color--gray--95);
+    .pf-v6-c-nav__link-text {
+        color: var(--pf-t--color--gray--10);
+    }
+    .pf-m-hamburger svg {
+        color: var(--pf-t--color--gray--10);
+    }
+}
+
+.pf-v6-c-compass__dock.pf-m-text-expanded {
+    width: 10em;
+}
+.pf-v6-c-masthead.pf-m-docked .pf-v6-c-masthead__main {
+    gap: var(--pf-t--global--spacer--xs);
+}
+
+.pf-v6-c-masthead__brand {
+    min-width: 0;
+}
+
+.pf-v6-c-compass__dock {
+    position: relative;
+    inset: initial;
+    width: auto;
+    translate: 0;
+}
+
+.nav-button-badge {
+    position: absolute;
+    top: 6px;
+    right: 2px;
+    margin: 0;
+    padding: 0 5px;
+    min-width: fit-content;
+    background-color: var(--pf-t--color--blue--30);
+    color: var(--pf-t--color--gray--95);
+    font-weight: normal;
+    font-size: 9px;
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/compass/AppDock.tsx 
b/karavan-app/src/main/webui/src/ui/compass/AppDock.tsx
new file mode 100644
index 00000000..8eae938a
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/AppDock.tsx
@@ -0,0 +1,192 @@
+import React, {useContext, useRef} from 'react';
+import {useLocation, useNavigate} from 'react-router-dom';
+import {
+    Badge,
+    Brand,
+    Button,
+    CompassDockMain,
+    Divider,
+    Masthead,
+    MastheadBrand,
+    MastheadContent,
+    MastheadLogo,
+    MastheadMain,
+    MastheadToggle,
+    Nav,
+    NavItem,
+    NavList,
+    Toolbar,
+    ToolbarContent,
+    ToolbarGroup,
+    ToolbarItem,
+    Tooltip
+} from '@patternfly/react-core';
+import logo from '../shared/icons/logo.svg';
+import {AuthContext} from "@api/auth/AuthProvider";
+import {getNavigationFirstMenu, getNavigationSecondMenu, MenuItem} from 
"@compass/navigation/NavigationMenu";
+import {useAppConfigStore, useDevModeStore, useFileStore} from 
"@stores/ProjectStore";
+import {shallow} from "zustand/shallow";
+import {BUILD_IN_PROJECTS} from "@models/ProjectModels";
+import {useUIStore} from "@stores/useUIStore";
+import {useCompassStore} from "./useCompassStore";
+import "./AppDock.css"
+import {useTheme} from "@compass/theme/ThemeContext";
+import {useAppConfig} from "@compass/useConfig";
+
+interface NavOnSelectProps {
+    groupId: number | string;
+    itemId: number | string;
+    to: string;
+}
+
+export const AppDock: React.FunctionComponent = () => {
+
+    const config = useAppConfigStore((s) => s.config);
+    const {isDark} = useTheme();
+    const {pageId, setPageId} = useUIStore();
+    const [setFile] = useFileStore((state) => [state.setFile], shallow)
+    const [setStatus, setPodName] = useDevModeStore((state) => 
[state.setStatus, state.setPodName], shallow)
+    const {isDockExpanded, isDockTextExpanded, setIsDockTextExpanded} = 
useCompassStore();
+    const {isDev} = useAppConfig();
+    const navigate = useNavigate();
+    const location = useLocation();
+    const {logout} = useContext(AuthContext);
+    const firstMenu = getNavigationFirstMenu()
+    const secondMenu = getNavigationSecondMenu(config.environment, 
config.infrastructure);
+
+    React.useEffect(() => {
+        const page = location.pathname?.split("/").filter(Boolean)[0];
+        if (page === 'projects') {
+            const projectId = location.pathname?.split("/").filter(Boolean)[1];
+            if (BUILD_IN_PROJECTS.includes(projectId)) {
+                setPageId('settings');
+            } else {
+                setPageId(page);
+            }
+        } else if (page !== undefined) {
+            setPageId(page);
+        } else if (config.environment === 'dev') {
+            setPageId('projects');
+        } else {
+            setPageId('dashboard');
+        }
+    }, [location]);
+
+
+    // Intercept PatternFly Nav selections and route via React Router
+    const onNavSelect = (_event: React.FormEvent<HTMLInputElement>, 
selectedItem: NavOnSelectProps) => {
+        if (selectedItem.to) {
+            navigate(selectedItem.to);
+        }
+    };
+
+    function onClick(page: MenuItem) {
+        if (page.pageId === 'logout') {
+            logout();
+        } else {
+            setFile('none', undefined);
+            setPodName(undefined);
+            setStatus("none");
+            setPageId(page.pageId);
+            navigate(page.pageId);
+        }
+    }
+
+    const dockedToggleRef = useRef<HTMLButtonElement>(null);
+
+    const onToggleDock = () => {
+        setIsDockTextExpanded(!isDockTextExpanded);
+    };
+
+    function getMenu(menu: MenuItem[]) {
+        return (
+            menu.filter(menuItem => isDev || (!menuItem.hideInNonDev && 
!isDev))
+                .map((menuItem, index) => {
+                const isSelected = pageId === menuItem.pageId;
+                const notExpanded = !isDockTextExpanded && !isDockExpanded;
+                const navItem =
+                    <NavItem
+                        key={menuItem.pageId}
+                        preventDefault
+                        id={menuItem.pageId}
+                        itemId={menuItem.pageId}
+                        isActive={isSelected}
+                        icon={menuItem.icon}
+                        aria-label={menuItem.name}
+                        onClick={() => onClick(menuItem)}
+                    >
+                        {isDockTextExpanded && menuItem.name}
+                    </NavItem>
+                if (notExpanded) {
+                    return <Tooltip key={menuItem.pageId} aria="none" 
aria-live="off" content={menuItem.name}>
+                        {navItem}
+                    </Tooltip>
+                } else {
+                    return (
+                        <div style={{position: "relative"}}>
+                            {navItem}
+                            {menuItem.preview && <Badge 
className='nav-button-badge'>Preview</Badge>}
+                        </div>
+                    )
+                }
+            })
+        )
+    }
+
+    return (
+        <CompassDockMain>
+            <Masthead display={{ default: 'inline' }} id="docked-masthead" 
variant="docked" className={isDark ? "" : "light-theme-dock"}>
+                <MastheadMain style={{display: 'flex', flexDirection: 
isDockTextExpanded ? 'row' : 'column'}}>
+                    <MastheadToggle>
+                        <Button
+                            ref={dockedToggleRef}
+                            variant="plain"
+                            isHamburger
+                            onClick={onToggleDock}
+                            aria-label="Global navigation"
+                            isExpanded={isDockTextExpanded}
+                        />
+                    </MastheadToggle>
+                    <MastheadBrand>
+                        <MastheadLogo isCompact>
+                        </MastheadLogo>
+                        <MastheadLogo>
+                            <div style={{display: 'flex', flexDirection: 
'row', alignItems: 'center', justifyContent: 'space-around',
+                                width: !isDockTextExpanded ? '100%' : '5em'
+                            }}>
+                                <Brand src={logo} alt="Karavan" 
heights={{default: '37px'}}/>
+                            </div>
+                        </MastheadLogo>
+                    </MastheadBrand>
+                </MastheadMain>
+                <Divider />
+                <MastheadContent>
+                    <Toolbar id="toolbar" isVertical>
+                        <ToolbarContent>
+                            <ToolbarItem>
+                                <Nav variant="docked" aria-label="First" 
ouiaId="IconNavFirst">
+                                    <NavList>
+                                        {getMenu(firstMenu)}
+                                    </NavList>
+                                </Nav>
+                            </ToolbarItem>
+                            <ToolbarGroup
+                                variant="action-group-plain"
+                                align={{ default: 'alignEnd' }}
+                                gap={{ default: 'gapNone', md: 'gapMd' }}
+                            >
+                                <ToolbarItem>
+                                    <Nav variant="docked" aria-label="Second" 
ouiaId="IconNavSecond">
+                                        <NavList>
+                                            {getMenu(secondMenu)}
+                                        </NavList>
+                                    </Nav>
+                                </ToolbarItem>
+                            </ToolbarGroup>
+                        </ToolbarContent>
+                    </Toolbar>
+                </MastheadContent>
+            </Masthead>
+        </CompassDockMain>
+    );
+};
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/compass/AppFooter.css 
b/karavan-app/src/main/webui/src/ui/compass/AppFooter.css
new file mode 100644
index 00000000..c426e6d9
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/AppFooter.css
@@ -0,0 +1,4 @@
+.prod-environment {
+    background-color: 
var(--pf-t--global--icon--color--status--danger--default);
+    color: var(--pf-t--global--background--color--secondary--default);
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/compass/AppFooter.tsx 
b/karavan-app/src/main/webui/src/ui/compass/AppFooter.tsx
new file mode 100644
index 00000000..e91d6228
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/AppFooter.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import {Badge, CompassMainFooter, Divider, Panel, PanelMain, PanelMainBody} 
from '@patternfly/react-core';
+import {useProjectStore} from "@stores/ProjectStore";
+import "./AppFooter.css"
+import {PlatformVersions} from "@shared/ui/PlatformLogos";
+import {BUILD_IN_PROJECTS} from "@models/ProjectModels";
+import {ProjectStatusLabel} from "@page-projects/table/ProjectStatusLabel";
+import {useAppConfig} from "@compass/useConfig";
+
+export const AppFooter: React.FunctionComponent = () => {
+
+    const {environment, infrastructure} = useAppConfig()
+    const project = useProjectStore((s) => s.project)
+    const isBuildIn = BUILD_IN_PROJECTS.includes(project?.projectId);
+    const {isDev} = useAppConfig();
+    const envClassName = isDev ? "" : "prod-environment"
+
+    const environmentUI =
+        <div style={{display: 'flex', flexDirection: 'row', justifyContent: 
'flex-start', gap: 6, alignItems:'center'}}>
+            <Badge isRead className={envClassName}>{environment}</Badge>
+            <Badge isRead className={envClassName}>{infrastructure}</Badge>
+        </div>
+
+    return (
+        <CompassMainFooter className={"app-footer"}>
+            <Panel isGlass style={{width: '100%', padding: 8}}>
+                <PanelMain>
+                    <PanelMainBody>
+                        <div style={{display: 'flex', flexDirection: 'row', 
alignItems: 'center', gap: 8}}>
+                            {/*{PlatformName(12, 175)}*/}
+                            <PlatformVersions/>
+                            <Divider orientation={{default: 'vertical'}}/>
+                            {environmentUI}
+                            <Divider orientation={{default: 'vertical'}}/>
+                            {!isBuildIn && <ProjectStatusLabel 
projectId={project?.projectId}/>}
+                            <div style={{flex: 1}}/>
+                        </div>
+                    </PanelMainBody>
+                </PanelMain>
+            </Panel>
+        </CompassMainFooter>
+    );
+};
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/compass/AppMain.css 
b/karavan-app/src/main/webui/src/ui/compass/AppMain.css
new file mode 100644
index 00000000..054fb28d
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/AppMain.css
@@ -0,0 +1,6 @@
+.pf-v6-c-panel__main-body {
+    padding-block-start: 0;
+    padding-block-end: 0;
+    padding-inline-start: 0;
+    padding-inline-end: 0;
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/compass/AppMain.tsx 
b/karavan-app/src/main/webui/src/ui/compass/AppMain.tsx
new file mode 100644
index 00000000..51845316
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/AppMain.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+import {CompassContent, CompassHeader, Panel, PanelMain, PanelMainBody} from 
'@patternfly/react-core';
+
+import "./AppMain.css"
+import {MainRoutes} from "@compass/navigation/MainRoutes";
+import {AppNavigation} from "./AppNavigation";
+import {AppFooter} from "@compass/AppFooter";
+
+export const AppMain: React.FunctionComponent = () => {
+
+    return (
+        <>
+            <CompassHeader logo={<AppNavigation/>}/>
+            <CompassContent>
+                <Panel isScrollable isAutoHeight isGlass style={{overflow: 
"hidden", border: "1px solid 
var(--pf-t--global--background--color--primary--default)"}}>
+                    <PanelMain style={{height:'100%'}}>
+                        <PanelMainBody style={{height:'100%'}}>
+                            <MainRoutes/>
+                        </PanelMainBody>
+                    </PanelMain>
+                </Panel>
+            </CompassContent>
+            <AppFooter/>
+        </>
+    );
+};
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/compass/AppNavigation.tsx 
b/karavan-app/src/main/webui/src/ui/compass/AppNavigation.tsx
new file mode 100644
index 00000000..a4262aee
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/AppNavigation.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import {CompassNavContent, CompassNavMain, Divider, Panel, PanelMain, 
PanelMainBody} from '@patternfly/react-core';
+import {useCompassStore} from "./useCompassStore";
+import {shallow} from "zustand/shallow";
+import DarkModeToggle from "./theme/DarkModeToggle";
+
+export const AppNavigation: React.FunctionComponent = () => {
+
+    const [pageNav, pageTools] = useCompassStore((s) => [
+        s.pageNav,
+        s.pageTools
+    ], shallow);
+
+    return (
+        <Panel isGlass>
+            <PanelMain>
+                <PanelMainBody>
+                    <CompassNavContent style={{justifyContent: 
'space-between'}}>
+                        <CompassNavMain>
+                            {pageNav}
+                        </CompassNavMain>
+                        <CompassNavMain>
+                            {pageTools && (
+                                <div style={{ display: 'flex', alignItems: 
'center', paddingBottom: 6, paddingTop: 6, gap: 6 }}>
+                                    {pageTools}
+                                    <Divider orientation={{default: 
'vertical'}}/>
+                                    <DarkModeToggle/>
+                                </div>
+                            )}
+                        </CompassNavMain>
+                    </CompassNavContent>
+                </PanelMainBody>
+            </PanelMain>
+        </Panel>
+    );
+};
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/compass/ReadinessPanel.tsx 
b/karavan-app/src/main/webui/src/ui/compass/ReadinessPanel.tsx
new file mode 100644
index 00000000..6e80972f
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/ReadinessPanel.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import '@compass/App.css';
+import {useReadinessStore} from "@stores/ReadinessStore";
+import {useDataPolling} from "@shared/polling/useDataPolling";
+import {Bullseye, Content, ContentVariants, Flex, FlexItem, ProgressStep, 
ProgressStepper, Spinner, Tooltip, TooltipPosition} from 
"@patternfly/react-core";
+import {useMainHook} from "@compass/useMainHook";
+import {PlatformLogoBase64} from "@compass/navigation/PlatformLogo";
+
+const FAST_INTERVAL = 1000; // 1 second (when not ready)
+const SLOW_INTERVAL = 10000; // 10 seconds (when ready)
+
+export function ReadinessPanel() {
+
+    const { readiness, fetchReadiness } = useReadinessStore();
+    const isReady = readiness && readiness.status === true;
+    const currentInterval = isReady ? SLOW_INTERVAL : FAST_INTERVAL;
+    useDataPolling('readiness', fetchReadiness, currentInterval);
+    const {showSpinner, showStepper} = useMainHook();
+
+    function getStepper() {
+        const steps: any[] = Array.isArray(readiness?.checks) ? 
readiness.checks : [];
+        return (
+            <Bullseye className="loading-page">
+                <Flex direction={{default: "column"}} 
justifyContent={{default: "justifyContentCenter"}}>
+                    <FlexItem style={{textAlign: "center"}}>
+                        <img src={PlatformLogoBase64()} className="logo" 
alt='logo'/>
+                        <Content>
+                            <Content component={ContentVariants.h2}>
+                                Waiting for services
+                            </Content>
+                        </Content>
+                    </FlexItem>
+                    <FlexItem>
+                        <ProgressStepper aria-label="Readiness progress" 
isCenterAligned isVertical>
+                            {steps.map(step => (
+                                <ProgressStep
+                                    key={step.name}
+                                    variant={step.status === 'UP' ? "success" 
: "info"}
+                                    isCurrent={step.status !== 'UP'}
+                                    icon={step.status !== 'UP' ? <Spinner 
isInline aria-label="Loading..."/> : undefined}
+                                    id={step.name}
+                                    titleId={step.name}
+                                    aria-label={step.name}
+                                >
+                                    {step.name}
+                                </ProgressStep>
+                            ))}
+                        </ProgressStepper>
+                    </FlexItem>
+                </Flex>
+            </Bullseye>
+        )
+    }
+
+    function getSpinner() {
+        return (
+            <Bullseye className="loading-page">
+                <Spinner className="spinner" diameter="140px" 
aria-label="Loading..."/>
+                <Tooltip content="Connecting to server..." 
position={TooltipPosition.bottom}>
+                    <div className="logo-placeholder">
+                        <img src={PlatformLogoBase64()} className="logo" 
alt='logo'/>
+                    </div>
+                </Tooltip>
+            </Bullseye>
+        )
+    }
+
+    return (
+        <>
+            {showSpinner() && getSpinner()}
+            {showStepper() && getStepper()}
+        </>
+    )
+}
diff --git 
a/karavan-app/src/main/webui/src/ui/compass/navigation/MainRoutes.tsx 
b/karavan-app/src/main/webui/src/ui/compass/navigation/MainRoutes.tsx
new file mode 100644
index 00000000..12276601
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/navigation/MainRoutes.tsx
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {Navigate, Route, Routes} from 'react-router-dom';
+import React, {lazy, Suspense} from "react";
+import {NotAuthorizedPage} from "@compass/navigation/NotAuthorizedPage";
+import {ROUTES} from "./Routes";
+import {ProtectedRoute} from "@compass/navigation/ProtectedRoute";
+import {PageFallback} from "@compass/navigation/PageFallback";
+import {LoginPage} from "@login/LoginPage";
+
+const ProjectsPage = lazy(() => import("@page-projects/ProjectsPage").then(m 
=> ({default: m.ProjectsPage})));
+const ProjectPage = lazy(() => import("@page-project/ProjectPage").then(m => 
({default: m.ProjectPage})));
+const SettingsPage = lazy(() => import("@page-settings/SettingsPage").then(m 
=> ({default: m.SettingsPage})));
+const SystemPage = lazy(() => import("@page-system/SystemPage").then(m => 
({default: m.SystemPage})));
+const DocumentationPage = lazy(() => 
import("@page-documentation/DocumentationPage").then(m => ({default: 
m.DocumentationPage})));
+const AccessPage = lazy(() => import("@page-access/AccessPage").then(m => 
({default: m.AccessPage})));
+
+export function MainRoutes() {
+
+    return (
+        <Suspense fallback={<PageFallback/>}>
+            <Routes>
+                <Route path={ROUTES.LOGIN} element={
+                    <ProtectedRoute>
+                        <LoginPage/>
+                    </ProtectedRoute>}
+                />
+                <Route path={ROUTES.PROJECTS} element={
+                    <ProtectedRoute>
+                        <ProjectsPage key="integrations"/>
+                    </ProtectedRoute>
+                }/>
+                <Route path={ROUTES.PROJECT_DETAIL} element={
+                    <ProtectedRoute>
+                        <ProjectPage key="project"/>
+                    </ProtectedRoute>
+                }/>
+                <Route path={ROUTES.PROJECT_FILE} element={
+                    <ProtectedRoute>
+                        <ProjectPage key="project"/>
+                    </ProtectedRoute>
+                }/>
+                <Route path={ROUTES.SETTINGS} element={
+                    <ProtectedRoute>
+                        <SettingsPage/>
+                    </ProtectedRoute>
+                }/>
+                <Route path={ROUTES.SETTINGS_FILE} element={
+                    <ProtectedRoute>
+                        <SettingsPage/>
+                    </ProtectedRoute>
+                }/>
+                <Route path={ROUTES.SYSTEM} element={
+                    <ProtectedRoute>
+                        <SystemPage/>
+                    </ProtectedRoute>
+                }/>
+                <Route path={ROUTES.DOCUMENTATION} element={
+                    <ProtectedRoute>
+                        <DocumentationPage/>
+                    </ProtectedRoute>
+                }/>
+                <Route path={ROUTES.ACL} element={
+                    <ProtectedRoute>
+                        <AccessPage/>
+                    </ProtectedRoute>
+                }/>
+                <Route path={ROUTES.FORBIDDEN} element={
+                    <NotAuthorizedPage/>
+                }/>
+                <Route path="*" element={<Navigate to={ROUTES.PROJECTS} 
replace/>}/>
+            </Routes>
+        </Suspense>
+    )
+}
diff --git 
a/karavan-app/src/main/webui/src/ui/compass/navigation/NavigationMenu.tsx 
b/karavan-app/src/main/webui/src/ui/compass/navigation/NavigationMenu.tsx
new file mode 100644
index 00000000..52b9466f
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/navigation/NavigationMenu.tsx
@@ -0,0 +1,52 @@
+import {AuthApi, getCurrentUser} from "@api/auth/AuthApi";
+import React from "react";
+import {SvgNavigationIcon} from "@shared/icons/SvgNavigationIcon";
+import {DockerIcon} from "@patternfly/react-icons";
+import {LogoKubernetes} from "@carbon/icons-react";
+
+export class MenuItem {
+    pageId: string = '';
+    name: string = '';
+    icon: any;
+    preview: boolean = false;
+    hideInNonDev: boolean = false;
+
+    constructor(pageId: string, name: string, icon: any, preview: boolean = 
false, hideInNonDev: boolean = false) {
+        this.pageId = pageId;
+        this.name = name;
+        this.icon = icon;
+        this.preview = preview;
+        this.hideInNonDev = hideInNonDev;
+    }
+}
+
+export function getNavigationFirstMenu(): MenuItem[] {
+    return [
+        new MenuItem("projects", "Projects", SvgNavigationIcon({icon: 
'apps'})),
+        new MenuItem("settings", "Settings", SvgNavigationIcon({icon: 
'settings'}), false, true)
+    ];
+}
+
+
+export function getNavigationSecondMenu(environment: string, infrastructure: 
string): MenuItem[] {
+    const iconInfra = infrastructure === 'kubernetes' ? <LogoKubernetes 
className={"infra-icon-k8s"}/> : <DockerIcon className='infra-icon-docker'/>;
+
+    const pages: MenuItem[] = []
+
+    if (environment === 'dev') {
+        pages.push(new MenuItem("documentation", "Learn", 
SvgNavigationIcon({icon: 'documentation'})));
+    }
+
+    if (getCurrentUser()?.roles?.includes('platform-admin')) {
+        pages.push(new MenuItem("system", "System", iconInfra));
+    }
+
+    if (AuthApi.authType === 'session') {
+        pages.push(new MenuItem("acl", "Access", SvgNavigationIcon({icon: 
'access'})));
+    }
+
+    pages.push(new MenuItem("logout", "Logout", SvgNavigationIcon({icon: 
'logout'})));
+
+    return pages;
+}
+
diff --git 
a/karavan-app/src/main/webui/src/ui/compass/navigation/NotAuthorizedPage.tsx 
b/karavan-app/src/main/webui/src/ui/compass/navigation/NotAuthorizedPage.tsx
new file mode 100644
index 00000000..d6f4bb0a
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/navigation/NotAuthorizedPage.tsx
@@ -0,0 +1,26 @@
+import React, {useEffect} from "react";
+import {Bullseye, EmptyState, EmptyStateBody, EmptyStateVariant} from 
"@patternfly/react-core";
+import {UserSecretIcon} from "@patternfly/react-icons";
+import {useNavigate} from "react-router-dom";
+import {getCurrentUser} from "@api/auth/AuthApi";
+
+export function NotAuthorizedPage() {
+
+    const navigate = useNavigate();
+
+    useEffect(() => {
+        const roles = getCurrentUser()?.roles ?? [];
+        const authorized = roles?.length > 0;
+        if (authorized) {
+            navigate("/");
+        }
+    }, []);
+
+    return (
+        <Bullseye>
+            <EmptyState  headingLevel="h4" icon={UserSecretIcon}  
titleText="Not authorized" variant={EmptyStateVariant.xl}>
+                <EmptyStateBody>You are not authorize to use this 
application</EmptyStateBody>
+            </EmptyState>
+        </Bullseye>
+    )
+}
diff --git 
a/karavan-app/src/main/webui/src/ui/compass/navigation/PageFallback.tsx 
b/karavan-app/src/main/webui/src/ui/compass/navigation/PageFallback.tsx
new file mode 100644
index 00000000..1a44c0ea
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/navigation/PageFallback.tsx
@@ -0,0 +1,13 @@
+import React from "react";
+import {Bullseye, Spinner} from "@patternfly/react-core";
+
+/**
+ * Shown while a lazily loaded page chunk is being fetched.
+ */
+export function PageFallback() {
+    return (
+        <Bullseye>
+            <Spinner aria-label="Loading page"/>
+        </Bullseye>
+    );
+}
diff --git 
a/karavan-app/src/main/webui/src/ui/compass/navigation/PlatformLogo.tsx 
b/karavan-app/src/main/webui/src/ui/compass/navigation/PlatformLogo.tsx
new file mode 100644
index 00000000..a59ff3a1
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/navigation/PlatformLogo.tsx
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from "react";
+import {KaravanIcon} from "@designer/icons/KaravanIcons";
+
+export function PlatformLogoBase64(customLogo?: string) {
+
+    return customLogo
+        ? customLogo
+        : 
'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgICAgICB3aWR0aD0iMzYwLjEzODg1IgogICAgICAgIGhlaWdodD0iMzYwLjEzODg1IgogICAgICAgIHZpZXdCb3g9IjAgMCAzNjAuMTM4ODUgMzYwLjEzODg1IgogICAgICAgIHZlcnNpb249IjEuMSIKICAgICAgICBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCIKICAgICAgICBpZD0ic3ZnNTAiCiAgICAgICAgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiCiAgICAgICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8ZGVmcwogICA
 [...]
+}
+
+export function PlatformLogo(classNames?: string) {
+    return KaravanIcon(classNames)
+}
+
+export default PlatformLogo;
diff --git 
a/karavan-app/src/main/webui/src/ui/compass/navigation/ProtectedRoute.tsx 
b/karavan-app/src/main/webui/src/ui/compass/navigation/ProtectedRoute.tsx
new file mode 100644
index 00000000..2a42d593
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/navigation/ProtectedRoute.tsx
@@ -0,0 +1,32 @@
+import {Navigate, useLocation} from "react-router-dom";
+import {JSX, useContext} from "react";
+import {AuthContext} from "@api/auth/AuthProvider";
+import {ROUTES} from "@compass/navigation/Routes";
+import {useReadinessStore} from "@stores/ReadinessStore";
+import {PageFallback} from "@compass/navigation/PageFallback";
+
+export function ProtectedRoute({ children }: { children: JSX.Element }) {
+    const { readiness } = useReadinessStore();
+    const { user, loading, authType } = useContext(AuthContext);
+    const location = useLocation();
+
+    if (readiness === undefined || readiness.status !== true) {
+        return children; // stay on loader page if already there
+    }
+
+    // OIDC never uses the internal login page: the browser is redirected to
+    // Keycloak by SsoApi, so show a spinner while that is in flight.
+    if (!user && authType === 'oidc') {
+        return <PageFallback/>;
+    }
+
+    if (!user && location.pathname !== ROUTES.LOGIN) {
+        return <Navigate to={ROUTES.LOGIN} state={{ from: location }} replace 
/>;
+    }
+
+    if (user && location.pathname === ROUTES.LOGIN) {
+        return <Navigate to={ROUTES.ROOT} state={{ from: location }} />;
+    }
+
+    return children;
+}
diff --git a/karavan-app/src/main/webui/src/ui/compass/navigation/Routes.ts 
b/karavan-app/src/main/webui/src/ui/compass/navigation/Routes.ts
new file mode 100644
index 00000000..3657c19a
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/navigation/Routes.ts
@@ -0,0 +1,15 @@
+// routes.ts
+export const ROUTES = {
+    PROJECTS: "/projects",
+    PROJECT_DETAIL: "/projects/:projectId",
+    PROJECT_FILE: "/projects/:projectId/:fileName",
+    SYSTEM: "/system",
+    SETTINGS: "/settings",
+    SETTINGS_FILE: "/settings/:projectId/:fileName",
+    DOCUMENTATION: "/documentation",
+    FORBIDDEN: "/403",
+    ACL: "/acl",
+    LOGIN: "/login",
+    // fallback redirect
+    ROOT: "/",
+};
diff --git a/karavan-app/src/main/webui/src/ui/compass/theme/DarkModeToggle.tsx 
b/karavan-app/src/main/webui/src/ui/compass/theme/DarkModeToggle.tsx
new file mode 100644
index 00000000..d8aa9ec3
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/theme/DarkModeToggle.tsx
@@ -0,0 +1,28 @@
+import {ToggleGroup, ToggleGroupItem} from '@patternfly/react-core';
+import {useTheme} from './ThemeContext';
+import {Moon, Sun} from "@carbon/icons-react";
+
+const DarkModeToggle = () => {
+    const { isDark, toggleDarkMode } = useTheme();
+
+    return (
+        <ToggleGroup aria-label="DarkModeToggle" 
className={"dark-mode-toggle"} isCompact>
+            <ToggleGroupItem
+                icon={<Sun className={"carbon"}/>}
+                aria-label="light"
+                buttonId="toggle-group-icons-1"
+                isSelected={!isDark}
+                onChange={(_, selected) => toggleDarkMode(!selected)}
+            />
+            <ToggleGroupItem
+                icon={<Moon className={"carbon"}/>}
+                aria-label="dark"
+                buttonId="toggle-group-icons-2"
+                isSelected={isDark}
+                onChange={(_, selected) => toggleDarkMode(selected)}
+            />
+        </ToggleGroup>
+    );
+};
+
+export default DarkModeToggle;
diff --git a/karavan-app/src/main/webui/src/ui/compass/theme/ThemeContext.tsx 
b/karavan-app/src/main/webui/src/ui/compass/theme/ThemeContext.tsx
new file mode 100644
index 00000000..965a87f0
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/theme/ThemeContext.tsx
@@ -0,0 +1,50 @@
+import React, {createContext, useContext, useEffect, useState} from 'react';
+
+interface ThemeContextType {
+    isDark: boolean;
+    toggleDarkMode: (checked: boolean) => void;
+}
+
+const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
+
+export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ 
children }) => {
+    const [isDark, setIsDark] = useState(false);
+
+    useEffect(() => {
+        const storedTheme = localStorage.getItem('pf-theme');
+
+        let shouldUseDark = false;
+
+        if (storedTheme === 'dark') {
+            shouldUseDark = true;
+        } else if (storedTheme === 'light') {
+            shouldUseDark = false;
+        } else {
+            // No stored preference → use browser preference
+            shouldUseDark = window.matchMedia('(prefers-color-scheme: 
dark)').matches;
+        }
+
+        setIsDark(shouldUseDark);
+        document.documentElement.classList.toggle('pf-v6-theme-dark', 
shouldUseDark);
+    }, []);
+
+    const toggleDarkMode = (checked: boolean) => {
+        setIsDark(checked);
+        document.documentElement.classList.toggle('pf-v6-theme-dark', checked);
+        localStorage.setItem('pf-theme', checked ? 'dark' : 'light');
+    };
+
+    return (
+        <ThemeContext.Provider value={{ isDark, toggleDarkMode }}>
+            {children}
+        </ThemeContext.Provider>
+    );
+};
+
+export const useTheme = (): ThemeContextType => {
+    const context = useContext(ThemeContext);
+    if (!context) {
+        throw new Error('useTheme must be used within a ThemeProvider');
+    }
+    return context;
+};
diff --git a/karavan-app/src/main/webui/src/ui/compass/useCompassStore.ts 
b/karavan-app/src/main/webui/src/ui/compass/useCompassStore.ts
new file mode 100644
index 00000000..ed9cc2b5
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/useCompassStore.ts
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from "react";
+import {shallow} from "zustand/shallow";
+import {createWithEqualityFn} from "zustand/traditional";
+
+interface CompassState {
+    isDockExpanded: boolean;
+    isDockTextExpanded: boolean;
+    isDrawerExpanded: boolean;
+
+    // Add state to hold the injected panel headers
+    pageTitle: React.ReactNode;
+    pageNav: React.ReactNode;
+    pageTools: React.ReactNode;
+    drawerPanel: React.ReactNode;
+
+    setIsDockExpanded: (isDockExpanded: boolean) => void;
+    setIsDockTextExpanded: (isDockTextExpanded: boolean) => void;
+    setIsDrawerExpanded: (isDrawerExpanded: boolean) => void;
+
+    // Action to set the context from RightPanel
+    setPageContext: (title: React.ReactNode, nav: React.ReactNode, tools: 
React.ReactNode, drawerPanel: React.ReactNode) => void;
+    setDrawerPanel: (drawerPanel: React.ReactNode) => void;
+}
+
+export const useCompassStore = createWithEqualityFn<CompassState>((set, get) 
=> ({
+    // Initial State
+    isDockExpanded: false,
+    isDockTextExpanded: false,
+    isDrawerExpanded: false,
+
+    pageTitle: null,
+    pageNav: null,
+    pageTools: null,
+    drawerPanel: null,
+
+    // Actions
+    setIsDockExpanded: (isDockExpanded) => set({ isDockExpanded }),
+    setIsDockTextExpanded: (isDockTextExpanded) => set({ isDockTextExpanded }),
+    setIsDrawerExpanded: (isDrawerExpanded) => set({ isDrawerExpanded }),
+
+    setPageContext: (pageTitle, pageNav, pageTools, drawerPanel) => set({ 
pageTitle, pageNav, pageTools, drawerPanel }),
+    setDrawerPanel: (drawerPanel) => set({ drawerPanel }),
+}), shallow);
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/compass/useConfig.tsx 
b/karavan-app/src/main/webui/src/ui/compass/useConfig.tsx
new file mode 100644
index 00000000..eccd7425
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/useConfig.tsx
@@ -0,0 +1,29 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {useAppConfigStore} from "@stores/ProjectStore";
+
+export const useAppConfig = () => {
+    const { config } = useAppConfigStore();
+
+    return {
+        isDev: config?.environment === 'dev',
+        environment: config?.environment,
+        environments: config?.environments,
+        infrastructure: config?.infrastructure,
+        isKubernetes: config?.infrastructure === 'kubernetes',
+    };
+};
diff --git a/karavan-app/src/main/webui/src/ui/compass/useMainHook.tsx 
b/karavan-app/src/main/webui/src/ui/compass/useMainHook.tsx
new file mode 100644
index 00000000..249c377a
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/compass/useMainHook.tsx
@@ -0,0 +1,153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {KaravanApi} from "@api/KaravanApi";
+import {ComponentApi} from "@core/api/ComponentApi";
+import {AppConfig, Project} from "@models/ProjectModels";
+import {useAppConfigStore, useProjectsStore} from "@stores/ProjectStore";
+import {InfrastructureAPI} from "@designer/utils/InfrastructureAPI";
+import {shallow} from "zustand/shallow";
+import {ProjectService} from "@services/ProjectService";
+import {SpiBeanApi} from "@core/api/SpiBeanApi";
+import {MainConfigurationApi} from "@core/api/MainConfigurationApi";
+import {useContext} from "react";
+import {AuthContext} from "@api/auth/AuthProvider";
+import {useReadinessStore} from "@stores/ReadinessStore";
+import {AuthApi} from "@api/auth/AuthApi";
+import {useContainerStatusesStore} from "@stores/ContainerStatusesStore";
+import {useTemplatesStore} from "@stores/SettingsStore";
+
+export function useMainHook () {
+
+    const {readiness} = useReadinessStore();
+    const [setConfig, setDockerInfo] = useAppConfigStore((s) => [s.setConfig, 
s.setDockerInfo], shallow)
+    const [setProjects] = useProjectsStore((s) => [s.setProjects], shallow)
+    const {fetchContainers} = useContainerStatusesStore();
+    const fetchTemplateFiles = useTemplatesStore((s) => s.fetchTemplateFiles);
+    const [selectedEnv, selectEnvironment] = useAppConfigStore((state) => 
[state.selectedEnv, state.selectEnvironment], shallow)
+    const { user } = useContext(AuthContext);
+
+    const getStatuses = () =>  {
+        if (user) {
+            fetchContainers();
+        }
+    }
+
+    const getData = () =>  {
+        if (user) {
+            KaravanApi.getConfiguration((config: AppConfig) => {
+                setConfig(config);
+                if (!selectedEnv || selectedEnv.length == 0) {
+                    selectEnvironment(config.environment, true);
+                }
+                if (config.infrastructure !== 'kubernetes') {
+                    KaravanApi.getInfrastructureInfo((info: any) => {
+                        setDockerInfo(info)
+                    })
+                }
+                InfrastructureAPI.infrastructure = config.infrastructure;
+            });
+            KaravanApi.getProjects((projects: Project[]) => {
+                setProjects(projects);
+            });
+            updateComponents();
+            updateBeans();
+            updateAllConfigurations();
+            fetchTemplateFiles();
+            ProjectService.loadCustomKamelets();
+            ProjectService.loadBlockedComponentAndKamelets();
+        }
+    }
+
+    async function updateComponents(): Promise<void> {
+        await new Promise(resolve => {
+            KaravanApi.getComponents(code => {
+                const components: [] = JSON.parse(code);
+                const jsons: string[] = [];
+                components.forEach(c => jsons.push(JSON.stringify(c)));
+                ComponentApi.saveComponents(jsons, true);
+            })
+        });
+    }
+
+    async function updateBeans(): Promise<void> {
+        await new Promise(resolve => {
+            KaravanApi.getBeans(code => {
+                const beans: [] = JSON.parse(code);
+                const jsons: string[] = [];
+                beans.forEach(c => jsons.push(JSON.stringify(c)));
+                SpiBeanApi.saveSpiBeans(jsons, true);
+            })
+        });
+    }
+
+    async function updateConfiguration(configName: string): Promise<{ 
properties: any[]; groups: any[] }> {
+        return new Promise(resolve => {
+            KaravanApi.getMetadataConfiguration(configName, code => {
+                try {
+                    const objects: any = JSON.parse(code);
+                    const properties: any[] = objects.properties || [];
+                    const groups: any[] = objects.groups || [];
+                    resolve({ properties, groups });
+                } catch (error) {
+                    console.error(`Failed to parse configuration for 
"${configName}":`, error);
+                    resolve({ properties: [], groups: [] });
+                }
+            });
+        });
+    }
+
+    async function updateConfigurationChanges(): Promise<any[]> {
+        return new Promise(resolve => {
+            KaravanApi.getConfigurationChanges(code => {
+                const changes: any = JSON.parse(code);
+                resolve(changes);
+            });
+        });
+    }
+
+    async function updateAllConfigurations(): Promise<void> {
+        const [meta, jbang, jib, jkube, changes] = await Promise.all([
+            updateConfiguration("main"),
+            updateConfiguration("jbang"),
+            updateConfiguration("jib"),
+            updateConfiguration("jkube"),
+            updateConfigurationChanges()
+        ]);
+
+        const properties: any[] = [...meta.properties, ...jbang.properties, 
...jib.properties, ...jkube.properties]
+        const groups: any[] = [...meta.groups, ...jbang.groups, ...jib.groups, 
...jkube.groups]
+
+        MainConfigurationApi.saveApplicationProperties(properties, true);
+        MainConfigurationApi.saveApplicationPropertyGroups(groups);
+        MainConfigurationApi.saveApplicationPropertyChanges(changes);
+    }
+
+    function showSpinner() {
+        return readiness === undefined;
+    }
+
+    function showStepper() {
+        return readiness !== undefined && readiness.status !== true;
+    }
+
+    function showApplication() {
+        return AuthApi.authType !== undefined && readiness?.status === true;
+    }
+
+
+    return { getData, getStatuses, showSpinner, showStepper, showApplication };
+}
\ No newline at end of file

Reply via email to