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 92e66a688115d2306df007bbc78bcd95378eb985
Author: Marat Gubaidullin <[email protected]>
AuthorDate: Mon Aug 24 17:56:34 2026 -0400

    Karavan-app UI Page Projects
---
 .../ui/page-projects/CreateProjectDrawerPanel.tsx  |   8 +-
 .../src/ui/page-projects/CreateProjectModal.tsx    | 150 +++++++
 .../src/ui/page-projects/CreateProjectPanel.tsx    |  11 +-
 .../src/ui/page-projects/DeleteProjectModal.tsx    |  69 ++++
 .../webui/src/ui/page-projects/ProjectZipApi.tsx   |  39 ++
 .../webui/src/ui/page-projects/ProjectsPage.css    |  44 ++
 .../webui/src/ui/page-projects/ProjectsPage.tsx    |  97 +++++
 .../webui/src/ui/page-projects/ProjectsToolbar.tsx | 122 ++++++
 .../src/ui/page-projects/ProjectsToolbarTags.tsx   |  59 +++
 .../src/ui/page-projects/UploadProjectModal.tsx    |  99 +++++
 .../ui/page-projects/architecture/Architecture.css | 128 ++++++
 .../architecture/ArchitectureComponentFactory.tsx  |  60 +++
 .../architecture/ArchitectureController.tsx        | 445 +++++++++++++++++++++
 .../architecture/ArchitectureEdge.tsx              |  29 ++
 .../architecture/ArchitectureForceLayout.ts        | 169 ++++++++
 .../architecture/ArchitectureHook.tsx              |  30 ++
 .../architecture/ArchitectureMenu.tsx              |  98 +++++
 .../architecture/ArchitectureNode.tsx              |  98 +++++
 .../architecture/ArchitectureRefresher.tsx         |  69 ++++
 .../page-projects/architecture/ArchitectureTab.tsx |  29 ++
 .../ui/page-projects/table/ProjectStatusLabel.tsx  |  73 ++++
 .../src/ui/page-projects/table/ProjectsTab.tsx     | 121 ++++++
 .../ui/page-projects/table/ProjectsTableRow.tsx    | 152 +++++++
 .../table/ProjectsTableRowActivity.css             |   9 +
 .../table/ProjectsTableRowActivity.tsx             |  20 +
 .../table/ProjectsTableRowTimeLine.css             |  43 ++
 .../table/ProjectsTableRowTimeLine.tsx             |  47 +++
 .../ui/page-projects/useCreateProjectFormUtil.tsx  |   2 +-
 28 files changed, 2308 insertions(+), 12 deletions(-)

diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectDrawerPanel.tsx 
b/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectDrawerPanel.tsx
index d13d5299..896ac4a2 100644
--- 
a/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectDrawerPanel.tsx
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectDrawerPanel.tsx
@@ -2,9 +2,9 @@ import React from 'react';
 import {Button, Content, Divider, DrawerHead, DrawerPanelContent,} from 
'@patternfly/react-core';
 import {useDashboardStore} from "@stores/DashboardStore";
 import {TimesIcon} from "@patternfly/react-icons";
-import {DashboardDevelopmentProjectPanel} from 
"./DashboardDevelopmentProjectPanel";
+import {CreateProjectPanel} from "./CreateProjectPanel";
 
-function DashboardDevelopmentDrawerPanel() {
+function CreateProjectDrawerPanel() {
 
     const {setShowSideBar, showSideBar, title} = useDashboardStore();
 
@@ -22,10 +22,10 @@ function DashboardDevelopmentDrawerPanel() {
                     </div>
                 </DrawerHead>
                 <Divider style={{marginTop: 0}}/>
-                {showSideBar === 'integration' && 
<DashboardDevelopmentProjectPanel/>}
+                {showSideBar === 'integration' && <CreateProjectPanel/>}
             </div>
         </DrawerPanelContent>
     )
 }
 
-export default DashboardDevelopmentDrawerPanel
\ No newline at end of file
+export default CreateProjectDrawerPanel
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectModal.tsx 
b/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectModal.tsx
new file mode 100644
index 00000000..f4300b55
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectModal.tsx
@@ -0,0 +1,150 @@
+/*
+ * 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, {useEffect} from 'react';
+import {Alert, Button, Form, FormAlert, Modal, ModalBody, ModalFooter, 
ModalHeader, ModalVariant} from '@patternfly/react-core';
+import {useProjectsStore, useProjectStore} from "@stores/ProjectStore";
+import {Project, RESERVED_WORDS} from "@models/ProjectModels";
+import {isValidProjectId, nameToProjectId} from "@utils/StringUtils";
+import {EventBus} from "@designer/utils/EventBus";
+import {SubmitHandler, useForm} from "react-hook-form";
+import {useFormUtil} from "@utils/useFormUtil";
+import {KaravanApi} from "@api/KaravanApi";
+import {AxiosResponse} from "axios";
+import {shallow} from "zustand/shallow";
+import {useNavigate} from "react-router-dom";
+import {ROUTES} from "@compass/navigation/Routes";
+
+export function CreateProjectModal() {
+
+    const [project, operation, setOperation] = useProjectStore((s) => 
[s.project, s.operation, s.setOperation], shallow);
+    const [projects, setProjects] = useProjectsStore((s) => [s.projects, 
s.setProjects], shallow);
+    const [isReset, setReset] = React.useState(false);
+    const [isProjectIdChanged, setIsProjectIdChanged] = React.useState(false);
+    const [backendError, setBackendError] = React.useState<string>();
+    const formContext = useForm<Project>({mode: "all"});
+    const {getTextField} = useFormUtil(formContext);
+    const {
+        formState: {errors},
+        handleSubmit,
+        reset,
+        trigger,
+        setValue,
+        getValues
+    } = formContext;
+    const navigate = useNavigate();
+
+    useEffect(() => {
+        const p = new Project();
+        if (operation === 'copy') {
+            p.projectId = project.projectId;
+            p.name = project.name;
+            p.type = project.type;
+        }
+        reset(p);
+        setBackendError(undefined);
+        setReset(true);
+    }, [reset]);
+
+    React.useEffect(() => {
+        isReset && trigger();
+    }, [trigger, isReset]);
+
+    function closeModal() {
+        setOperation("none");
+    }
+
+    const onSubmit: SubmitHandler<Project> = (data) => {
+        if (operation === 'copy') {
+            KaravanApi.copyProject(project.projectId, data, after)
+        } else {
+            KaravanApi.postProject(data, after)
+        }
+    }
+
+    function after (result: boolean, res: AxiosResponse<Project> | any) {
+        if (result) {
+            onSuccess(res.data.projectId);
+        } else {
+            setBackendError(res?.response?.data);
+        }
+    }
+
+    function onSuccess (projectId: string) {
+        const message = operation !== "copy" ? "Project successfully created." 
: "Project successfully copied.";
+        EventBus.sendAlert( "Success", message, "success");
+        KaravanApi.getProjects((projects: Project[]) => {
+            setProjects(projects);
+            setOperation("none");
+            navigate(`${ROUTES.PROJECTS}/${projectId}`);
+        });
+    }
+
+    function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>): void {
+        if (event.key === 'Enter') {
+            handleSubmit(onSubmit)()
+        }
+    }
+
+    function onNameChange (value: string) {
+        if (!isProjectIdChanged) {
+            setValue('projectId', nameToProjectId(value), {shouldValidate: 
true})
+        }
+    }
+    function onIdChange (value: string) {
+        setIsProjectIdChanged(true)
+    }
+
+    return (
+        <Modal
+            variant={ModalVariant.small}
+            isOpen={["create", "copy"].includes(operation)}
+            onClose={closeModal}
+            onKeyDown={onKeyDown}
+        >
+
+            <ModalHeader title={operation !== 'copy' ? "Create Project" : 
"Copy Project from " + project?.projectId}/>
+            <ModalBody>
+                <Form isHorizontal={true} autoComplete="off">
+                    {getTextField('name', 'Name', {
+                        length: v => v.length > 5 || 'Project name should be 
longer that 5 characters',
+                    }, 'text', onNameChange)}
+                    {getTextField('projectId', 'Project ID', {
+                        regex: v => isValidProjectId(v) || 'Only lowercase 
characters, numbers and dashes allowed',
+                        length: v => v.length > 5 || 'Project ID should be 
longer that 5 characters',
+                        name: v => !RESERVED_WORDS.includes(v) || "Reserved 
word",
+                        uniques: v => !projects.map(p=> p.name).includes(v) || 
"Project already exists!",
+                    }, 'text', onIdChange)}
+                    {backendError &&
+                        <FormAlert>
+                            <Alert variant="danger" title={backendError} 
aria-live="polite" isInline />
+                        </FormAlert>
+                    }
+                </Form>
+            </ModalBody>
+            <ModalFooter>
+                <Button key="confirm" variant="primary"
+                        onClick={handleSubmit(onSubmit)}
+                        isDisabled={Object.getOwnPropertyNames(errors).length 
> 0}
+                >
+                    Save
+                </Button>
+                <Button key="cancel" variant="secondary" 
onClick={closeModal}>Cancel</Button>
+            </ModalFooter>
+        </Modal>
+    )
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectPanel.tsx 
b/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectPanel.tsx
index 11725316..0de070fe 100644
--- a/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectPanel.tsx
+++ b/karavan-app/src/main/webui/src/ui/page-projects/CreateProjectPanel.tsx
@@ -14,29 +14,26 @@ import {useNavigate} from "react-router-dom";
 import {ROUTES} from "@compass/navigation/Routes";
 import {ProjectFunctionHook} from "@page-project/ProjectFunctionHook";
 import {KaravanApi} from "@api/KaravanApi";
-import {useCommandPaletteStore} from "@command-palette/useCommandPaletteStore";
 import {CommandPaletteFooter} from "@command-palette/CommandPaletteFooter";
 import {DslMetaModel} from "@designer/utils/DslMetaModel";
-import {useFormUtil} from "./useFormUtil";
+import {useCreateProjectFormUtil} from "./useCreateProjectFormUtil";
 
 const CommandPalettePanel = lazy(() => 
import("@command-palette/CommandPalettePanel").then(m => ({default: 
m.CommandPalettePanel})));
 
-export function DashboardDevelopmentProjectPanel() {
+export function CreateProjectPanel() {
 
     const projects = useProjectsStore((s) => s.projects);
     const fetchProjectInfos = useProjectInfoStore((s) => s.fetchProjectInfos);
     const showSideBar = useDashboardStore(state => state.showSideBar);
     const setShowSideBar = useDashboardStore(state => state.setShowSideBar);
-    const filter = useCommandPaletteStore(state => state.filter);
 
     const [isProjectIdChanged, setIsProjectIdChanged] = React.useState(false);
     const [backendError, setBackendError] = React.useState<string>();
-    const {createOpenApiForProject, createRoutesForEmptyProject, 
createDlqForEmptyProject} = ProjectFunctionHook();
+    const {createRoutesForEmptyProject, createDlqForEmptyProject} = 
ProjectFunctionHook();
     const navigate = useNavigate();
 
-    // 1. Setup Form
     const formContext = useForm<Project>({mode: "all"});
-    const {getTextField, getCheckbox} = useFormUtil(formContext);
+    const {getTextField, getCheckbox} = useCreateProjectFormUtil(formContext);
     const {reset, setValue, setFocus, handleSubmit} = formContext;
 
     // 2. Prepare Data
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/DeleteProjectModal.tsx 
b/karavan-app/src/main/webui/src/ui/page-projects/DeleteProjectModal.tsx
new file mode 100644
index 00000000..8d8fb18c
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-projects/DeleteProjectModal.tsx
@@ -0,0 +1,69 @@
+/*
+ * 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, {useState} from 'react';
+import {Content, ContentVariants, HelperText, HelperTextItem, Switch} from 
'@patternfly/react-core';
+import {useProjectStore} from "@stores/ProjectStore";
+import {ProjectService} from "@services/ProjectService";
+import {shallow} from "zustand/shallow";
+import {ModalConfirmation} from "@shared/ui/ModalConfirmation";
+
+export function DeleteProjectModal() {
+
+    const [project, operation] = useProjectStore((s) => [s.project, 
s.operation], shallow);
+    const [deleteContainers, setDeleteContainers] = useState(false);
+
+    function closeModal() {
+        useProjectStore.setState({operation: "none", project: undefined});
+    }
+
+    function confirmAndCloseModal() {
+        ProjectService.deleteProject(project, deleteContainers);
+        useProjectStore.setState({operation: "none", project: undefined});
+    }
+
+    const isOpen = operation === "delete";
+    return (
+        <ModalConfirmation
+            isOpen={isOpen}
+            message={
+                <>
+                    <Content>
+                        <Content component={ContentVariants.h3}>Delete project 
<b>{project?.projectId}</b> ?</Content>
+                        <HelperText>
+                            <HelperTextItem variant="warning">
+                                Project will be also deleted from <b>git</b> 
repository
+                            </HelperTextItem>
+                        </HelperText>
+                        <Content component={ContentVariants.p}></Content>
+                        <Content component={ContentVariants.p}></Content>
+                    </Content>
+                    <Switch
+                        label={"Delete related container and/or deployments?"}
+                        isChecked={deleteContainers}
+                        onChange={(_, checked) => setDeleteContainers(checked)}
+                        isReversed
+                    />
+                </>
+            }
+            btnConfirm='Delete'
+            btnConfirmVariant='danger'
+            onConfirm={() => confirmAndCloseModal()}
+            onCancel={() => closeModal()}
+        />
+    )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/page-projects/ProjectZipApi.tsx 
b/karavan-app/src/main/webui/src/ui/page-projects/ProjectZipApi.tsx
new file mode 100644
index 00000000..33a866f1
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-projects/ProjectZipApi.tsx
@@ -0,0 +1,39 @@
+import axios from "axios";
+import {ErrorEventBus} from "@bus/ErrorEventBus";
+import {AuthApi} from "@api/auth/AuthApi";
+
+axios.defaults.headers.common['Accept'] = 'application/json';
+axios.defaults.headers.common['Content-Type'] = 'application/json';
+const instance = AuthApi.getInstance();
+
+export class ProjectZipApi {
+
+    static async downloadZip(projectId: string, after: (res: any) => void) {
+        instance.get('/ui/zip/project/' + projectId,
+            {
+                responseType: 'blob', headers: {'Accept': 
'application/octet-stream'}
+            }).then(response => {
+            after(response.data);
+        }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async uploadZip(fileHandle: File, after: (res: any) => void) {
+        const formData = new FormData();
+        formData.append('file', fileHandle);
+        formData.append('name', fileHandle.name);
+
+        instance.post('/ui/zip/project', formData,
+            {headers: {'Content-Type': 'multipart/form-data'}}
+        ).then(res => {
+            if (res.status === 200) {
+                after(res);
+            } else {
+                after(undefined);
+            }
+        }).catch(err => {
+            after(err);
+        });
+    }
+}
diff --git a/karavan-app/src/main/webui/src/ui/page-projects/ProjectsPage.css 
b/karavan-app/src/main/webui/src/ui/page-projects/ProjectsPage.css
new file mode 100644
index 00000000..201ab493
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-projects/ProjectsPage.css
@@ -0,0 +1,44 @@
+/*
+ * 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.
+ */
+.karavan .projects-toolbar {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    gap: 8px;
+    padding: 16px;
+    position: relative;
+}
+
+.karavan .projects-toolbar::before {
+    position: absolute;
+    inset: 0;
+    pointer-events: none;
+    content: "";
+    border-bottom: var(--pf-t--global--border--color--default) solid 
var(--pf-t--global--border--width--box--default);
+    border-radius: inherit;
+}
+
+.karavan .projects-toolbar .label-selector {
+    display: flex;
+    justify-content: start;
+    align-items: center;
+    width: 100%;
+    flex-wrap: wrap;
+    .pf-v6-c-button {
+        padding: 2px 0 2px 6px;
+    }
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/page-projects/ProjectsPage.tsx 
b/karavan-app/src/main/webui/src/ui/page-projects/ProjectsPage.tsx
new file mode 100644
index 00000000..c57b8190
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-projects/ProjectsPage.tsx
@@ -0,0 +1,97 @@
+import React, {useEffect, useState} from 'react';
+import {capitalize, Content, Tab, Tabs, TabsComponent, TabTitleText,} from 
'@patternfly/react-core';
+import {RightPanel} from "@shared/ui/RightPanel";
+import {BUILD_IN_PROJECTS} from "@models/ProjectModels";
+import {useFileStore, useProjectsStore, useProjectStore} from 
"@stores/ProjectStore";
+import {shallow} from "zustand/shallow";
+import {DeveloperManager} from "@developer/DeveloperManager";
+import {ErrorBoundaryWrapper} from "@shared/ui/ErrorBoundaryWrapper";
+import {ProjectsTab} from "./table/ProjectsTab";
+import {ProjectFunctionHook} from "@page-project/ProjectFunctionHook";
+import {useDataPolling} from "@shared/polling/useDataPolling";
+import {useContainerStatusesStore} from "@stores/ContainerStatusesStore";
+import {ArchitectureTab} from "./architecture/ArchitectureTab";
+import "./ProjectsPage.css"
+import {TabProps} from "@patternfly/react-core/src/components/Tabs/Tab";
+import {useDashboardStore} from "@stores/DashboardStore";
+import CreateProjectDrawerPanel from "./CreateProjectDrawerPanel";
+
+export const IntegrationsMenus = ['integrations', 'architecture'] as const;
+export type IntegrationsMenu = typeof IntegrationsMenus[number];
+
+export function ProjectsPage() {
+
+    const [fetchProjects, projects, fetchProjectsCommited, fetchProjectLabels] 
=
+        useProjectsStore((s) => [s.fetchProjects, s.projects, 
s.fetchProjectsCommited, s.fetchProjectLabels], shallow)
+    const [setProject] = useProjectStore((s) => [s.setProject], shallow);
+    const {fetchContainers} = useContainerStatusesStore();
+    const {showSideBar, setShowSideBar} = useDashboardStore();
+    const [file, operation, setFile] = useFileStore((s) => [s.file, 
s.operation, s.setFile], shallow);
+    const showFilePanel = file !== undefined && operation === 'select';
+    const [currentMenu, setCurrentMenu] = 
useState<IntegrationsMenu>(IntegrationsMenus[0]);
+
+    const {refreshSharedData} = ProjectFunctionHook();
+    useDataPolling('ProjectPanel', fetchContainers, 7000);
+
+    useEffect(() => {
+        fetchProjects();
+        fetchProjectsCommited();
+        refreshSharedData();
+        fetchProjectLabels();
+        return () => {
+            setShowSideBar(null);
+        }
+    }, []);
+
+    function title() {
+        return (<Content component="h2">Projects</Content>)
+    }
+
+    const onNavSelect = (event: React.MouseEvent<HTMLElement, MouseEvent>, 
eventKey: TabProps['eventKey']) => {
+        setCurrentMenu(eventKey as IntegrationsMenu);
+        const isBuildIn = BUILD_IN_PROJECTS.includes(eventKey?.toString());
+        if (isBuildIn) {
+            const p = projects.find(p => p.projectId === eventKey);
+            if (p) {
+                setProject(p, "select");
+            }
+        }
+        setFile('none', undefined);
+    };
+
+
+    function getNavigation() {
+        return (
+            <Tabs
+                onSelect={onNavSelect}
+                isNav
+                component={TabsComponent.nav}
+                activeKey={currentMenu}
+            >
+                {IntegrationsMenus.map((item, i) =>
+                    <Tab
+                        key={item}
+                        eventKey={item}
+                        title={<TabTitleText>{capitalize(item)}</TabTitleText>}
+                    />
+                )}
+            </Tabs>
+        )
+    }
+
+    return (
+        <RightPanel
+            title={title()}
+            toolsStart={getNavigation()}
+            tools={undefined}
+            drawerPanel={<CreateProjectDrawerPanel/>}
+            mainPanel={
+                <ErrorBoundaryWrapper onError={error => console.error(error)}>
+                    {!showFilePanel && currentMenu === 'architecture' && 
<ArchitectureTab/>}
+                    {!showFilePanel && currentMenu === 'integrations' && 
<ProjectsTab/>}
+                    {showFilePanel && <DeveloperManager/>}
+                </ErrorBoundaryWrapper>
+            }
+        />
+    )
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/ProjectsToolbar.tsx 
b/karavan-app/src/main/webui/src/ui/page-projects/ProjectsToolbar.tsx
new file mode 100644
index 00000000..f0de558c
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-projects/ProjectsToolbar.tsx
@@ -0,0 +1,122 @@
+import React, {useEffect, useState} from 'react';
+import {Button, TextInputGroup, TextInputGroupMain, TextInputGroupUtilities, 
Tooltip, TooltipPosition,} from '@patternfly/react-core';
+import {CodeBranchIcon, SearchIcon, SyncAltIcon, TimesIcon} from 
'@patternfly/react-icons';
+import {shallow} from "zustand/shallow";
+import {ProjectService} from "@services/ProjectService";
+import {useSearchStore} from "@stores/SearchStore";
+import {useDebounceValue} from "usehooks-ts";
+import {SearchApi} from "@api/SearchApi";
+import {UploadProjectModal} from "./UploadProjectModal";
+import {ModalConfirmation} from "@shared/ui/ModalConfirmation";
+import {ProjectsToolbarTags} from "./ProjectsToolbarTags";
+import {useAppConfig} from "@compass/useConfig";
+import {useDashboardStore} from "@stores/DashboardStore";
+
+export interface ProjectsToolbarProps {
+    type: "full" | "simple"
+}
+
+export function ProjectsToolbar(props: ProjectsToolbarProps) {
+
+    const {type} = props;
+    const [search, setSearch, setSearchResults] = useSearchStore((s) => 
[s.search, s.setSearch, s.setSearchResults], shallow)
+    const {setShowSideBar, showSideBar} = useDashboardStore();
+    const [showUpload, setShowUpload] = useState<boolean>(false);
+    const [debouncedSearch] = useDebounceValue(search, 300);
+    const [pullIsOpen, setPullIsOpen] = useState(false);
+    const {isDev} = useAppConfig();
+
+    function refreshDesign() {
+        ProjectService.refreshProjects();
+    }
+
+    useEffect(() => refreshDesign(), [showSideBar]);
+
+    useEffect(() => {
+        if (search !== undefined && search !== '') {
+            SearchApi.searchAll(search, response => {
+                if (response) {
+                    setSearchResults(response);
+                }
+            })
+        } else {
+            setSearchResults([])
+        }
+    }, [debouncedSearch]);
+
+    function searchInput() {
+        return (
+            <TextInputGroup style={{width: "300px"}}>
+                <TextInputGroupMain
+                    value={search}
+                    id="search-input"
+                    // placeholder='Search'
+                    type="text"
+                    autoComplete={"off"}
+                    autoFocus={true}
+                    icon={<SearchIcon/>}
+                    onChange={(_event, value) => {
+                        setSearch(value);
+                    }}
+                    aria-label="text input example"
+                />
+                <TextInputGroupUtilities>
+                    <Button variant="plain" onClick={_ => {
+                        setSearch('');
+                    }}>
+                        <TimesIcon aria-hidden={true}/>
+                    </Button>
+                </TextInputGroupUtilities>
+            </TextInputGroup>
+        )
+    }
+
+    const additionalElements =
+        <>
+            <Tooltip content='Pull new Integrations from git' 
position={TooltipPosition.left}>
+                <Button icon={<CodeBranchIcon/>}
+                        variant={"link"}
+                        isDanger
+                        onClick={e => setPullIsOpen(true)}/>
+            </Tooltip>
+            {searchInput()}
+        </>
+
+    const devElements =
+        <>
+            {isDev &&
+                <Button className="dev-action-button" variant="primary"
+                        onClick={e => setShowSideBar("integration", "Create 
Apache Camel integration project")}>
+                    Create Integration
+                </Button>
+            }
+            {isDev &&
+                <Button className="dev-action-button" variant="tertiary"
+                        onClick={e => setShowUpload(true)}>
+                    Import project
+                </Button>
+            }
+            {showUpload && <UploadProjectModal open={showUpload} onClose={() 
=> setShowUpload(false)}/>}
+            <ModalConfirmation isOpen={pullIsOpen}
+                               message='Pull new Integrations from Git!'
+                               onConfirm={() => {
+                                   ProjectService.pullAllProjects();
+                                   setPullIsOpen(false);
+                               }}
+                               onCancel={() => setPullIsOpen(false)}
+                               btnConfirmVariant='danger'
+                               btnConfirm='Confirm Pull'
+            />
+        </>
+
+    return (
+        <div className="projects-toolbar" style={{justifyContent: 
"space-between"}}>
+            <ProjectsToolbarTags/>
+            <Button icon={<SyncAltIcon/>}
+                    variant={"link"}
+                    onClick={e => refreshDesign()}/>
+            {type === "full" && additionalElements}
+            {devElements}
+        </div>
+    )
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/ProjectsToolbarTags.tsx 
b/karavan-app/src/main/webui/src/ui/page-projects/ProjectsToolbarTags.tsx
new file mode 100644
index 00000000..0045ce9b
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-projects/ProjectsToolbarTags.tsx
@@ -0,0 +1,59 @@
+import * as React from 'react';
+import {Button, Content, Label} from '@patternfly/react-core';
+import {Star, StarFilled, StarHalf} from "@carbon/icons-react";
+import {PROJECT_WITH_NO_LABELS, useProjectsStore} from "@stores/ProjectStore";
+
+export function ProjectsToolbarTags() {
+
+    const { labels, selectedLabels, setSelectedLabels} = useProjectsStore();
+    const allSelected = labels?.length === selectedLabels.length;
+    const allIcon = allSelected
+        ? <StarFilled className={'carbon'}/>
+        : (selectedLabels?.length > 0 ? <StarHalf className={'carbon'}/> : 
<Star className={'carbon'}/>);
+    return (
+        <div className="label-selector">
+            <Content component={'p'}>Labels</Content>
+            <Button variant={'link'}
+                    isInline
+                    onClick={(_) => {
+                        if (allSelected) {
+                            setSelectedLabels([]);
+                        } else {
+                            setSelectedLabels([...labels.map(t => t)]);
+                        }
+                    }}
+            >
+                <Label status={allSelected || selectedLabels?.length > 0 ? 
'success' : 'info'}
+                       variant={allSelected ? 'outline' : 'outline'}
+                       icon={allIcon}
+                >
+                    all
+                </Label>
+            </Button>
+            {labels?.sort().map((label) => {
+                const isSelected = selectedLabels.includes(label);
+                const icon = isSelected ? <StarFilled className={'carbon'}/> : 
<Star className={'carbon'}/>;
+                return (
+                    <Button key={label}
+                            variant={'link'}
+                            isInline
+                            onClick={_ => {
+                                if (isSelected) {
+                                    
setSelectedLabels([...selectedLabels.filter(t => t !== label)]);
+                                } else {
+                                    setSelectedLabels([...selectedLabels, 
label]);
+                                }
+                            }}
+                    >
+                        <Label status={isSelected ? 'success' : 'info'}
+                               variant={isSelected ? 'outline' : 'outline'}
+                               icon={icon}
+                        >
+                            {label !== PROJECT_WITH_NO_LABELS ? label : 'no 
label'}
+                        </Label>
+                    </Button>
+                );
+            })}
+        </div>
+    )
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/UploadProjectModal.tsx 
b/karavan-app/src/main/webui/src/ui/page-projects/UploadProjectModal.tsx
new file mode 100644
index 00000000..7b5bcc2c
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-projects/UploadProjectModal.tsx
@@ -0,0 +1,99 @@
+import React, {useState} from 'react';
+import {Button, Content, FileUpload, Form, FormGroup, Modal, ModalBody, 
ModalFooter, ModalHeader, ModalVariant,} from '@patternfly/react-core';
+import {Accept, DropEvent} from "react-dropzone";
+import {EventBus} from "@designer/utils/EventBus";
+import {ProjectService} from "@services/ProjectService";
+import {ProjectZipApi} from "./ProjectZipApi";
+import {ErrorEventBus} from "@bus/ErrorEventBus";
+
+interface Props {
+    open: boolean,
+    onClose: () => void
+}
+
+export function UploadProjectModal(props: Props) {
+
+    const [value, setValue] = React.useState<File>();
+    const [filename, setFilename] = React.useState<string>();
+    const [isLoading, setIsLoading] = useState(false);
+    const [isRejected, setIsRejected] = useState(false);
+
+    const handleFileInputChange = (_: any, file: File) => {
+        setFilename(file.name);
+    };
+
+    const onReadFinished = (event: DropEvent, fileHandle: File): void => {
+        setValue(fileHandle);
+        setIsLoading(false)
+    }
+
+    const handleClear = (_event: React.MouseEvent<HTMLButtonElement, 
MouseEvent>) => {
+        setFilename(undefined);
+        setValue(undefined);
+    };
+
+
+    function onConfirm(){
+        if (filename !== undefined && value !== undefined) {
+            ProjectZipApi.uploadZip(value, res => {
+                if (res.status === 200) {
+                    EventBus.sendAlert( "Success", "Integration uploaded", 
"success");
+                    ProjectService.refreshProjects();
+                } else if (res.status === 304) {
+                    EventBus.sendAlert( "Attention", "Integration already 
exists", "warning");
+                } else {
+                    ErrorEventBus.sendApiError(res);
+                }
+            })
+            closeModal();
+        }
+    }
+
+    function closeModal() {
+        props.onClose?.()
+    }
+
+    const accept : Accept = {'application/x-zip': ['.zip']};
+    return (
+        <Modal
+            title="Upload project"
+            variant={ModalVariant.small}
+            isOpen={props.open}
+            onClose={closeModal}
+        >
+            <ModalHeader>
+                <Content component='h2'>Import Integration</Content>
+            </ModalHeader>
+            <ModalBody>
+                <Form>
+                    <FormGroup fieldId="upload">
+                        <FileUpload
+                            id="file-upload"
+                            value={value}
+                            filename={filename}
+                            type="dataURL"
+                            hideDefaultPreview
+                            browseButtonText="Upload"
+                            isLoading={isLoading}
+                            onFileInputChange={handleFileInputChange}
+                            onReadStarted={(_event, fileHandle: File) => 
setIsLoading(true)}
+                            onReadFinished={onReadFinished}
+                            allowEditingUploadedText={false}
+                            onClearClick={handleClear}
+                            dropzoneProps={{accept: accept, onDropRejected: 
fileRejections => setIsRejected(true)}}
+                        />
+                    </FormGroup>
+                </Form>
+            </ModalBody>
+            <ModalFooter>
+                <Button key="confirm" variant="primary"
+                        onClick={event => onConfirm()}
+                        isDisabled={filename === undefined || value === 
undefined}
+                >
+                    Save
+                </Button>
+                <Button key="cancel" variant="secondary" 
onClick={closeModal}>Cancel</Button>
+            </ModalFooter>
+        </Modal>
+    )
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/architecture/Architecture.css 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/Architecture.css
new file mode 100644
index 00000000..4ad9b52c
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/Architecture.css
@@ -0,0 +1,128 @@
+.projects-architecture-page {
+    display: flex;
+    flex-direction: column;
+    width: 100%;
+    height: 100%;
+}
+
+.projects-architecture-page .projects-architecture-panel {
+
+    .node-transparent {
+        display: none;
+    }
+
+    .integration-node{
+        .pf-topology__node__label__background {
+            fill: none;
+            stroke: none;
+        }
+    }
+
+    .integration-node .icon {
+        height: 32px;
+        width: 32px;
+    }
+    .integration-node-up {
+        .pf-topology__node__label__background {
+            /*stroke:var(--pf-t--color--green--60);*/
+        }
+        .pf-topology__node__background {
+            stroke:var(--pf-t--color--green--60);
+        }
+        .pf-topology__node__label {
+            .pf-topology__node__label__background {
+                stroke: var(--pf-t--color--green--60);
+            }
+        }
+        svg {
+            fill: var(--pf-t--color--green--60);
+        }
+    }
+    .integration-node-up.pf-m-selected {
+        .pf-topology__node__label__background {
+            fill: var(--pf-t--color--green--60);
+        }
+    }
+    .project-up {
+        .pf-topology__node__background {
+            stroke: transparent;
+        }
+
+    }
+    .running-halo {
+        fill: none; /* Crucial: no fill, so it doesn't hide the node */
+        stroke: var(--pf-t--color--green--60);
+        stroke-width: 2px; /* The thickness of the ring */
+        stroke-linecap: round; /* Gives the dash segments smooth, rounded ends 
*/
+        stroke-dasharray: 13 5;
+        transform-origin: 25px 25px;
+        animation: spin 4s linear infinite;
+    }
+    .integration-node-stats {
+        svg {
+            visibility: hidden;
+        }
+    }
+    .edge-running {
+        stroke: var(--pf-t--color--green--60);
+        .pf-topology-connector-arrow {
+            fill: var(--pf-t--color--green--60);
+            stroke: var(--pf-t--color--green--60);
+        }
+    }
+    .edge-transparent {
+        display: none;
+    }
+}
+
+.projects-architecture-page .projects-architecture-panel .node-stats {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: space-between;
+    justify-items: center;
+    width: 100%;
+    height: 100%;
+    .metric {
+        font-weight: normal;
+    }
+
+    .inflight-color {
+        color: var(--pf-t--global--background--color--primary--default);
+        background-color: var(--pf-t--global--color--brand--default);
+    }
+
+    .failed-color {
+        color: var(--pf-t--global--text--color--status--on-danger--default);
+        background-color: var(--pf-t--global--color--status--danger--default);
+    }
+    .total-color {
+        color: var(--pf-t--global--text--color--status--on-success--default);
+        background-color: var(--pf-t--global--color--status--success--default);
+    }
+}
+
+.pf-topology-context-menu__c-dropdown__menu {
+    .pf-v6-c-menu__content {
+        gap: 2px;
+        .pf-v6-c-menu__item {
+            padding: 8px 14px;
+        }
+    }
+}
+
+@keyframes spin {
+    0% {
+        transform: rotate(0deg);
+    }
+    100% {
+        transform: rotate(360deg);
+    }
+}
+
+/*Compass*/
+.pf-v6-c-compass {
+    .karavan .projects-architecture-page .projects-architecture-panel {
+
+    }
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureComponentFactory.tsx
 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureComponentFactory.tsx
new file mode 100644
index 00000000..f3718f07
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureComponentFactory.tsx
@@ -0,0 +1,60 @@
+import {
+    DefaultGroup,
+    DragObjectWithType,
+    Edge,
+    GraphComponent,
+    graphDropTargetSpec,
+    GraphElement,
+    ModelKind,
+    Node,
+    withContextMenu,
+    withDndDrop,
+    withPanZoom,
+    withSelection,
+    withTargetDrag,
+} from '@patternfly/react-topology';
+import {ArchitectureMenus} from "./ArchitectureMenu";
+import {ArchitectureNode} from "./ArchitectureNode";
+import ArchitectureEdge from "../architecture/ArchitectureEdge";
+
+const CONNECTOR_TARGET_DROP = 'connector-target-drop';
+
+export function getArchitectureComponentFactory() {
+    return function (kind: ModelKind, type: string) {
+        switch (type) {
+            case 'group':
+                return (withContextMenu(element => 
ArchitectureMenus(element))(withSelection()(DefaultGroup)));
+            default:
+                switch (kind) {
+                    case ModelKind.graph:
+                        return 
withDndDrop(graphDropTargetSpec())(withPanZoom()(GraphComponent));
+                    case ModelKind.node:
+                        return withContextMenu(element => 
ArchitectureMenus(element))(withSelection()(ArchitectureNode));
+                    case ModelKind.edge:
+                        return withTargetDrag<DragObjectWithType, Node, { 
dragging?: boolean }, { element: GraphElement; }>({
+                            item: {type: CONNECTOR_TARGET_DROP},
+                            begin: (monitor, props) => {
+                                props.element.raise();
+                                return props.element;
+                            },
+                            drag: (event, monitor, props) => {
+                                (props.element as Edge).setEndPoint(event.x, 
event.y);
+                            },
+                            end: (dropResult: Node | undefined, monitor, 
props) => {
+                                if (monitor.didDrop() && dropResult !== 
undefined && props) {
+                                    (props.element as 
Edge).setTarget(dropResult);
+                                }
+                                (props.element as Edge).setEndPoint();
+                            },
+                            collect: (monitor) => ({
+                                dragging: monitor.isDragging()
+                            })
+                        })(withSelection()(ArchitectureEdge));
+                    default:
+                        return undefined;
+                }
+        }
+    };
+}
+
+export default getArchitectureComponentFactory
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureController.tsx
 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureController.tsx
new file mode 100644
index 00000000..1c89d7c3
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureController.tsx
@@ -0,0 +1,445 @@
+import * as React from 'react';
+import './Architecture.css';
+import {
+    action,
+    Controller,
+    createTopologyControlButtons,
+    defaultControlButtonsOptions,
+    EdgeAnimationSpeed,
+    EdgeModel,
+    EdgeStyle,
+    ForceLayout,
+    Graph,
+    GRAPH_LAYOUT_END_EVENT,
+    Layout,
+    Model,
+    NodeModel,
+    NodeShape,
+    NodeStatus,
+    SELECTION_EVENT,
+    SELECTION_STATE,
+    Visualization,
+} from '@patternfly/react-topology';
+import {useProjectsStore} from "@stores/ProjectStore";
+import {EventBus} from "@designer/utils/EventBus";
+import {runInAction} from "mobx";
+import {useProjectInfoStore} from "@stores/useProjectInfoStore";
+import {ProjectInfo} from "@models/CatalogModels";
+import {ArrayNumbers, Template} from "@carbon/icons-react";
+import {CONSUMER_PREFIX, NODE_DIAMETER_INOUT, NODE_DIAMETER_PROJECT, 
PRODUCER_PREFIX, PROJECT_ID_PREFIX, STANDALONE_NODE_ID, STANDALONE_PREFIX} from 
"./ArchitectureHook";
+import getArchitectureComponentFactory from "./ArchitectureComponentFactory";
+import {TopologyUtils} from "@core/api/TopologyUtils";
+import {CamelDefinitionApi} from "@core/api/CamelDefinitionApi";
+import {INTERNAL_COMPONENTS} from "@core/api/ComponentApi";
+import {TopologyDagreLayout} from 
"@page-project/project-topology/graph/TopologyDagreLayout";
+import {useArchitectureStore} from "@stores/ArchitectureStore";
+import {capitalize} from "@patternfly/react-core";
+import {EyeIcon, EyeSlashIcon} from "@patternfly/react-icons";
+import {compareUri} from "@core/api/UriUtil";
+import {ComplexityRouteType} from "@models/ComplexityModels";
+import {TopologyElkLayout} from 
"@page-project/project-topology/graph/TopologyElkLayout";
+
+export function ArchitectureController() {
+
+    const {projectLabels, selectedLabels} = useProjectsStore();
+    const [setFileName, showGroups, setShowGroups, showRouteTemplates, 
setShowRouteTemplates]
+        = useArchitectureStore((s) => [s.setFileName, s.showGroups, 
s.setShowGroups, s.showRouteTemplates, s.setShowRouteTemplates]);
+    const [showStats, setShowStats, layout, nextLayout]
+        = useArchitectureStore((s) => [s.showStats, s.setShowStats, s.layout, 
s.nextLayout]);
+    const [projects] = useProjectsStore((s) => [s.projects]);
+    const [projectInfos] = useProjectInfoStore(s => [s.projectInfos]);
+
+    function isProjectRunning(info: ProjectInfo): boolean {
+        const isBuildRunning = info?.isBuildRunning ?? false;
+        const isDevModeRunning = info?.isDevModeRunning ?? false;
+        const isPackagedRunning = info?.isPackagedRunning ?? false;
+        return isDevModeRunning || isPackagedRunning || isBuildRunning;
+    }
+
+    function getIntegrationNodes(infos: ProjectInfo[]): NodeModel[] {
+        const resultNodes: NodeModel[] = [];
+        infos.map(info => {
+            const projectId = info.projectId;
+            const isRunning = isProjectRunning(info);
+            const name = projects.find(p => p.projectId === projectId)?.name
+            const projectNodeId = `${PROJECT_ID_PREFIX}${projectId}`;
+            const routesCount = info.routes?.length;
+            const routesTooltip = "No Routes";
+            const projectNode: NodeModel = {
+                id: projectNodeId,
+                type: 'node',
+                label: name ?? projectId,
+                width: NODE_DIAMETER_PROJECT,
+                height: NODE_DIAMETER_PROJECT,
+                shape: NodeShape.circle,
+                status: NodeStatus.default,
+                data: {
+                    prefix: PROJECT_ID_PREFIX,
+                    projectId: projectId,
+                    exposesOpenApi: info.exposesOpenApi,
+                    showStats: showStats,
+                    isRunning: isRunning,
+                    statusTooltip: (
+                        <div style={{display: 'flex', flexDirection: 'column', 
gap: 4}}>
+                            {routesCount === 0 && <div>{routesTooltip}</div>}
+                        </div>
+                    ),
+                }
+            }
+            resultNodes.push(projectNode);
+        });
+        return resultNodes;
+    }
+
+    function getComponentNodes(infos: ProjectInfo[]): NodeModel[] {
+        const resultNodes: NodeModel[] = [];
+        infos.map(info => {
+            const projectId = info.projectId;
+            const isRunning = isProjectRunning(info);
+            const projectNodeId = `${PROJECT_ID_PREFIX}${projectId}`;
+            const routes = showRouteTemplates === true ? info.routes : 
info.routes.filter(r => r.type !== ComplexityRouteType.ROUTE_TEMPlATE);
+            routes?.forEach((route) => {
+                route.consumers
+                    ?.filter(c => !INTERNAL_COMPONENTS.includes(c.name) && 
c.remote === true)
+                    ?.forEach((component) => {
+                        const uniqueUri = 
TopologyUtils.getUniqueUri(CamelDefinitionApi.createFromDefinition({uri: 
component.name, parameters: component.parameters}));
+                        const componentNodeId = route.routeId + "_" + 
component.id;
+                        const node: NodeModel = {
+                            id: uniqueUri,
+                            type: 'node',
+                            label: TopologyUtils.getUriLabel(uniqueUri) ?? 
component.id,
+                            width: NODE_DIAMETER_INOUT,
+                            height: NODE_DIAMETER_INOUT,
+                            shape: NodeShape.circle,
+                            data: {
+                                prefix: CONSUMER_PREFIX,
+                                component: component,
+                                projectId: projectId,
+                                projectNodeId: projectNodeId,
+                                showStats: showStats,
+                                isRunning: isRunning,
+                            }
+                        }
+                        resultNodes.push(node);
+                    });
+                route.producers
+                    ?.filter(c => !INTERNAL_COMPONENTS.includes(c.name) && 
c.remote === true)
+                    ?.forEach((component) => {
+                        const uniqueUri = 
TopologyUtils.getUniqueUri(CamelDefinitionApi.createFromDefinition({uri: 
component.name, parameters: component.parameters}));
+                        const componentNodeId = route.routeId + "_" + 
component.id;
+                        const node: NodeModel = {
+                            id: uniqueUri,
+                            type: 'node',
+                            label: (TopologyUtils.getUriLabel(uniqueUri) ?? 
component.id),
+                            width: NODE_DIAMETER_INOUT,
+                            height: NODE_DIAMETER_INOUT,
+                            shape: NodeShape.circle,
+                            data: {
+                                prefix: PRODUCER_PREFIX,
+                                component: component,
+                                projectId: projectId,
+                                projectNodeId: projectNodeId,
+                                showStats: showStats,
+                                isRunning: isRunning,
+                            }
+                        }
+                        resultNodes.push(node);
+                    })
+            })
+        });
+        return resultNodes;
+    }
+
+    function getIntegrationEdges(infos: ProjectInfo[]): EdgeModel[] {
+        const result: EdgeModel[] = [];
+        infos.map(info => {
+            const projectId = info.projectId;
+            const isRunning = isProjectRunning(info);
+            const projectNodeId = `${PROJECT_ID_PREFIX}${projectId}`;
+            const routes = showRouteTemplates === true ? info.routes : 
info.routes.filter(r => r.type !== ComplexityRouteType.ROUTE_TEMPlATE);
+            routes?.forEach((route) => {
+                route.consumers
+                    ?.filter(c => !INTERNAL_COMPONENTS.includes(c.name) && 
c.remote === true)
+                    ?.forEach((component) => {
+                        const uniqueUri = 
TopologyUtils.getUniqueUri(CamelDefinitionApi.createFromDefinition({uri: 
component.name, parameters: component.parameters}));
+                        const componentNodeId = route.routeId + "_" + 
component.id;
+                        const edge: EdgeModel = {
+                            id: 'edge-' + componentNodeId + '-' + 
projectNodeId,
+                            type: 'edge',
+                            source: uniqueUri,
+                            target: projectNodeId,
+                            edgeStyle: EdgeStyle.dashedMd,
+                            animationSpeed: isRunning ? 
EdgeAnimationSpeed.medium : EdgeAnimationSpeed.none,
+                            data: {
+                                isRunning: isRunning
+                            }
+                        }
+                        result.push(edge);
+                    });
+                route.producers
+                    ?.filter(c => !INTERNAL_COMPONENTS.includes(c.name) && 
c.remote === true)
+                    ?.forEach((component) => {
+                        const uniqueUri = 
TopologyUtils.getUniqueUri(CamelDefinitionApi.createFromDefinition({uri: 
component.name, parameters: component.parameters}));
+                        const componentNodeId = route.routeId + "_" + 
component.id;
+                        const edge: EdgeModel = {
+                            id: 'edge-' + projectNodeId + '-' + 
componentNodeId,
+                            type: 'edge',
+                            source: projectNodeId,
+                            target: uniqueUri,
+                            edgeStyle: EdgeStyle.dashedMd,
+                            animationSpeed: isRunning ? 
EdgeAnimationSpeed.medium : EdgeAnimationSpeed.none,
+                            data: {
+                                isRunning: isRunning
+                            }
+                        }
+                        result.push(edge);
+                    })
+            })
+        });
+        return result;
+    }
+
+    function getWildCardUriEdges(nodes: NodeModel[]): EdgeModel[] {
+        const result: EdgeModel[] = [];
+        const consumers = nodes.filter(n => n.data?.prefix === 
CONSUMER_PREFIX);
+        const producers = nodes.filter(n => n.data?.prefix === 
PRODUCER_PREFIX);
+        producers.forEach(producer => {
+            consumers
+                .filter(consumer => (producer.id !== consumer.id) && 
compareUri(producer.id, consumer.id))
+                .forEach(consumer => {
+                    const edge: EdgeModel = {
+                        id: 'edge-wildcard-uri-' + producer.id + '-' + 
consumer.id,
+                        type: 'edge',
+                        source: producer.id,
+                        target: consumer.id,
+                        edgeStyle: EdgeStyle.dotted,
+                        animationSpeed: (consumer.data?.isRunning && 
producer.data?.isRunning) ? EdgeAnimationSpeed.medium : EdgeAnimationSpeed.none,
+                        data: {}
+                    }
+                    result.push(edge);
+                })
+        })
+        return result;
+    }
+
+
+    /**
+     * A component node only carries information when it ties different 
projects together, so keep
+     * the ones whose edges reach more than one project and drop the edges 
left dangling by the
+     * removed nodes. A remote URI shared by several projects is a single node 
id reached by one
+     * integration edge per project, plus wildcard edges to the matching URIs 
of other projects.
+     */
+    function filterConnectedComponentNodes(componentNodes: NodeModel[], edges: 
EdgeModel[]): [NodeModel[], EdgeModel[]] {
+        // The same component node id occurs once per project using that URI
+        const projectIdsByComponentNodeId = new Map<string, Set<string>>();
+        const projectIdByProjectNodeId = new Map<string, string>();
+        componentNodes.forEach(node => {
+            const projectId = node.data?.projectId;
+            const projectNodeId = node.data?.projectNodeId;
+            if (projectId === undefined) {
+                return;
+            }
+            if (!projectIdsByComponentNodeId.has(node.id)) {
+                projectIdsByComponentNodeId.set(node.id, new Set<string>());
+            }
+            projectIdsByComponentNodeId.get(node.id)?.add(projectId);
+            if (projectNodeId !== undefined) {
+                projectIdByProjectNodeId.set(projectNodeId, projectId);
+            }
+        });
+
+        function getProjectIdsOf(nodeId?: string): string[] {
+            if (nodeId === undefined) {
+                return [];
+            }
+            const projectId = projectIdByProjectNodeId.get(nodeId);
+            return projectId !== undefined ? [projectId] : 
[...(projectIdsByComponentNodeId.get(nodeId) ?? [])];
+        }
+
+        // Collect, per component node, every project reachable through its 
edges
+        const reachableProjectIds = new Map<string, Set<string>>();
+
+        function addReachableProjects(nodeId?: string, otherNodeId?: string) {
+            if (nodeId === undefined || 
!projectIdsByComponentNodeId.has(nodeId)) {
+                return;
+            }
+            if (!reachableProjectIds.has(nodeId)) {
+                reachableProjectIds.set(nodeId, new Set<string>());
+            }
+            getProjectIdsOf(otherNodeId).forEach(projectId => 
reachableProjectIds.get(nodeId)?.add(projectId));
+        }
+
+        edges.forEach(edge => {
+            addReachableProjects(edge.source, edge.target);
+            addReachableProjects(edge.target, edge.source);
+        });
+
+        const sharedNodes = componentNodes.filter(node => 
(reachableProjectIds.get(node.id)?.size ?? 0) > 1);
+        const sharedNodeIds = new Set(sharedNodes.map(node => node.id));
+        // Only an edge pointing at a component node that got dropped is 
dangling
+        const sharedEdges = edges.filter(edge => [edge.source, edge.target]
+            .every(nodeId => nodeId === undefined || 
!projectIdsByComponentNodeId.has(nodeId) || sharedNodeIds.has(nodeId)));
+
+        return [sharedNodes, sharedEdges];
+    }
+
+    /**
+     * A project without any component connection has nothing holding it in 
place, so all of them
+     * are attached to one shared node that acts as their common anchor.
+     */
+    function getStandaloneNode(): NodeModel {
+        return {
+            id: STANDALONE_NODE_ID,
+            type: 'node',
+            label: 'Standalone',
+            width: NODE_DIAMETER_INOUT,
+            height: NODE_DIAMETER_INOUT,
+            shape: NodeShape.circle,
+            data: {
+                prefix: STANDALONE_PREFIX,
+                showStats: false,
+                isRunning: false,
+            }
+        }
+    }
+
+    function getStandaloneEdges(integrationNodes: NodeModel[], edges: 
EdgeModel[]): EdgeModel[] {
+        // Integration edges are the only ones touching a project node, so a 
project missing from
+        // the surviving edges is a project with no component to connect to
+        const connectedNodeIds = new Set(edges.flatMap(edge => [edge.source, 
edge.target]));
+        return integrationNodes
+            .filter(node => !connectedNodeIds.has(node.id))
+            .map(node => ({
+                id: 'edge-standalone-' + node.id,
+                type: 'edge',
+                source: STANDALONE_NODE_ID,
+                target: node.id,
+                data: {
+                    isTransparent: true
+                }
+            }));
+    }
+
+    function getModel(): Model {
+        const nodes: NodeModel[] = [];
+        const edges: EdgeModel[] = [];
+        const projectInfosWithSelected = projectInfos.filter((project) => {
+            const projectTags = projectLabels[project.projectId] || [];
+            return projectTags.some((label: string) => 
selectedLabels.includes(label));
+        });
+        const integrationNodes = getIntegrationNodes(projectInfosWithSelected);
+        const componentNodes = getComponentNodes(projectInfosWithSelected);
+        const integrationEdges = getIntegrationEdges(projectInfosWithSelected);
+        // Wildcard edges are matched against every component node, before any 
of them are dropped
+        const wildCardUriEdges = getWildCardUriEdges(componentNodes);
+        const [connectedComponentNodes, connectedEdges] =
+            filterConnectedComponentNodes(componentNodes, 
[...integrationEdges, ...wildCardUriEdges]);
+        const standaloneEdges = getStandaloneEdges(integrationNodes, 
connectedEdges);
+        nodes.push(...integrationNodes)
+        nodes.push(...connectedComponentNodes)
+        // The anchor is only worth showing when something actually hangs off 
it
+        if (standaloneEdges.length > 0) {
+            nodes.push(getStandaloneNode())
+        }
+        edges.push(...connectedEdges)
+        edges.push(...standaloneEdges)
+        return {nodes: nodes, edges: edges, graph: {id: 'graph', type: 
'graph', layout: 'elements'}};
+    }
+
+    function customLayoutFactory(type: string, graph: Graph): Layout {
+        if (layout === 'dagre') {
+            return new TopologyDagreLayout(graph, {}, true);
+        } else if (layout === 'elk') {
+            return new TopologyElkLayout(graph, {});
+        } else {
+            return new ForceLayout(graph, {});
+        }
+    }
+
+    const controller = React.useMemo(() => {
+        const visualization = new Visualization();
+        try {
+            const model = getModel();
+            visualization.registerLayoutFactory((type, graph) => 
customLayoutFactory(type, graph));
+            
visualization.registerComponentFactory(getArchitectureComponentFactory());
+            visualization.addEventListener(GRAPH_LAYOUT_END_EVENT, () => {
+                runInAction(() => {
+                    visualization.getGraph().fit(90);
+                });
+            });
+            visualization.fromModel(model, false);
+        } catch (error: any) {
+            console.error(error);
+            EventBus.sendAlert('Error', error?.message, 'danger');
+        }
+        return visualization;
+    }, [projectInfos, showGroups, showStats, layout, showRouteTemplates, 
selectedLabels, projectLabels]);
+
+    function clearAllSelection(ctrl: Controller) {
+        runInAction(() => {
+            ctrl.getElements().forEach((e) => {
+                const state: any = ctrl.getState();
+                state[SELECTION_STATE] = [];
+            });
+            ctrl.fireEvent(SELECTION_EVENT, []);
+        })
+    }
+
+    function getButtonTitle(title: string, icon: React.ReactNode) {
+        return (
+            <div>
+                {icon}
+                <span style={{marginLeft: '3px'}}>{title}</span>
+            </div>
+        )
+    }
+
+    const controlButtons = React.useMemo(() => {
+        return createTopologyControlButtons({
+            ...defaultControlButtonsOptions,
+            zoomInCallback: action(() => {
+                controller.getGraph().scaleBy(4 / 3);
+            }),
+            zoomOutCallback: action(() => {
+                controller.getGraph().scaleBy(0.75);
+            }),
+            legendHidden: true,
+            fitToScreenCallback: action(() => {
+                controller.getGraph().fit(200);
+            }),
+            resetViewCallback: action(() => {
+                controller.getGraph().reset();
+                controller.getGraph().layout();
+            }),
+            customButtons: [
+                // {
+                //     id: 'showGroups',
+                //     icon: showGroups ? getButtonTitle('Grouped', 
<GroupObjects className='carbon'/> ) : getButtonTitle('Ungrouped', 
<UngroupObjects className='carbon'/>) ,
+                //     tooltip: 'Switch Ungrouped/Grouped',
+                //     callback: id => setShowGroups(!showGroups)
+                // },
+                {
+                    id: 'layout',
+                    icon: getButtonTitle(capitalize(layout), <Template 
className='carbon'/>),
+                    tooltip: 'Switch Layout',
+                    callback: id => nextLayout()
+                },
+                {
+                    id: "stats",
+                    icon: <ArrayNumbers className='carbon'/>,
+                    tooltip: showStats ? "Hide stats" : "Show stats",
+                    callback: (id: any) => setShowStats(!showStats),
+                },
+                {
+                    id: 'showRouteTemplates',
+                    icon: showRouteTemplates ? getButtonTitle('Templates', 
<EyeIcon/>) : getButtonTitle('Templates', <EyeSlashIcon/>),
+                    tooltip: 'Show/Hide Templates',
+                    callback: id => setShowRouteTemplates(!showRouteTemplates)
+                },
+            ],
+        });
+    }, [controller, controller, showGroups, showRouteTemplates, 
selectedLabels]);
+
+
+    return {clearAllSelection, controller, controlButtons}
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureEdge.tsx
 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureEdge.tsx
new file mode 100644
index 00000000..1e5ad548
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureEdge.tsx
@@ -0,0 +1,29 @@
+import * as React from 'react';
+
+import {DefaultEdge, EdgeTerminalType, NodeStatus, observer} from 
'@patternfly/react-topology';
+
+const ArchitectureEdge: React.FC<any> = observer(({ element, ...rest }) => {
+
+    const data = element.getData();
+
+    if (data.invisible) return undefined;
+    let className = data.isRunning ? "edge-running" : "";
+    if (data.isTransparent) {
+        className = 'edge-transparent';
+    }
+
+    return (
+        <DefaultEdge
+            element={element}
+            startTerminalType={EdgeTerminalType.none}
+            endTerminalType={EdgeTerminalType.directional}
+            endTerminalSize={10}
+            endTerminalStatus={data?.endTerminalStatus || NodeStatus.default}
+            tagStatus={data?.endTerminalStatus || NodeStatus.default}
+            tag={data.label}
+            className={className}
+            {...rest}
+        />
+    )
+})
+export default ArchitectureEdge;
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureForceLayout.ts
 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureForceLayout.ts
new file mode 100644
index 00000000..dc0f73c4
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureForceLayout.ts
@@ -0,0 +1,169 @@
+import {BaseLayout, getGroupPadding, Graph, GRAPH_LAYOUT_END_EVENT, Layout, 
LayoutLink, LayoutNode, LayoutOptions} from "@patternfly/react-topology";
+import {ForceSimulationNode} from 
"@patternfly/react-topology/src/layouts/ForceSimulation";
+
+// BaseLayout defaults to no repulsion at all, which lets unrelated clusters 
settle on top of each
+// other and drags their edges across the graph. These give the simulation 
room to separate them.
+const CHARGE_STRENGTH = -350;
+const COLLIDE_DISTANCE = 10;
+
+// Extra link length per connection beyond the first, so the neighbours of a 
busy node fan out
+// around it instead of folding over each other
+const HUB_LINK_DISTANCE = 14;
+const MAX_HUB_LINK_DISTANCE = 140;
+
+export class ArchitectureForceLayout extends BaseLayout implements Layout {
+
+    private degreeByNodeId = new Map<string, number>();
+
+    constructor(graph: Graph, options?: Partial<LayoutOptions>) {
+        super(graph, {
+            chargeStrength: CHARGE_STRENGTH,
+            collideDistance: COLLIDE_DISTANCE,
+            ...options,
+            layoutOnDrag: true,
+            onSimulationEnd: () => {
+                this.nodes.forEach((n) => n.setFixed(false));
+                this.graph.getController().fireEvent(GRAPH_LAYOUT_END_EVENT, { 
graph: this.graph });
+            }
+        });
+    }
+
+    protected getLinkDistance = (e: LayoutLink | 
d3.SimulationLinkDatum<ForceSimulationNode>) => {
+        // The busiest endpoint decides: a hub needs spokes long enough for 
its neighbours to spread
+        const maxDegree = Math.max(this.getDegree(e.source?.id), 
this.getDegree(e.target?.id));
+        const hubDistance = Math.min(MAX_HUB_LINK_DISTANCE, HUB_LINK_DISTANCE 
* Math.max(0, maxDegree - 1));
+
+        let distance = this.options.linkDistance + hubDistance + 
e.source.radius * 1.3 + e.target.radius * 1.3;
+        const isFalse = e instanceof LayoutLink && e.isFalse;
+        if (!isFalse && e.source.element.getParent() !== 
e.target.element.getParent()) {
+            // find the group padding
+            distance += getGroupPadding(e.source.element.getParent());
+            distance += getGroupPadding(e.target.element.getParent());
+        }
+
+        return distance;
+    };
+
+    /**
+     * BaseLayout seeds every node on the exact centre of the graph, leaving 
the simulation to
+     * untangle a start where nothing has a meaningful position - which is 
where most of the edge
+     * crossings come from, since the winding it happens to resolve into is 
arbitrary. Seeding the
+     * nodes around a circle in breadth first order instead puts neighbours 
side by side up front,
+     * so the simulation only has to relax a layout that is already roughly 
untangled.
+     */
+    protected initializeNodePositions(nodes: LayoutNode[], graph: Graph, 
force: boolean): void {
+        this.degreeByNodeId = this.getDegreeByNodeId();
+
+        const unpositioned = nodes.filter(node => force || 
!node.element.isPositioned());
+        const unpositionedIds = new Set(unpositioned.map(node => node.id));
+        // Anything already placed stays where it is, as BaseLayout does
+        nodes.filter(node => !unpositionedIds.has(node.id)).forEach(node => 
node.setFixed(true));
+        if (unpositioned.length === 0) {
+            return;
+        }
+
+        const {width, height} = graph.getBounds();
+        const cx = width / 2;
+        const cy = height / 2;
+        const ordered = this.getBreadthFirstOrder(unpositioned);
+        const radius = this.getSeedRadius(ordered);
+        ordered.forEach((node, index) => {
+            const angle = (2 * Math.PI * index) / ordered.length;
+            node.setPosition(cx + radius * Math.cos(angle), cy + radius * 
Math.sin(angle));
+        });
+    }
+
+    /**
+     * Walks each connected component breadth first, starting from its best 
connected node and
+     * taking the best connected neighbours first, so that the neighbours of a 
node end up as a
+     * contiguous run on the seed circle rather than as chords reaching across 
it.
+     */
+    private getBreadthFirstOrder(nodes: LayoutNode[]): LayoutNode[] {
+        const nodeById = new Map(nodes.map(node => [node.id, node]));
+        const neighbourIds = new Map<string, string[]>();
+
+        function addNeighbour(nodeId?: string, neighbourId?: string) {
+            if (nodeId === undefined || neighbourId === undefined) {
+                return;
+            }
+            if (!neighbourIds.has(nodeId)) {
+                neighbourIds.set(nodeId, []);
+            }
+            neighbourIds.get(nodeId)?.push(neighbourId);
+        }
+
+        this.edges.forEach(edge => {
+            addNeighbour(edge.source?.id, edge.target?.id);
+            addNeighbour(edge.target?.id, edge.source?.id);
+        });
+
+        // Id is the tie breaker so the same model always seeds identically
+        const byDegree = (a: LayoutNode, b: LayoutNode) =>
+            this.getDegree(b.id) - this.getDegree(a.id) || 
a.id.localeCompare(b.id);
+
+        const visited = new Set<string>();
+        const ordered: LayoutNode[] = [];
+        [...nodes].sort(byDegree).forEach(start => {
+            if (visited.has(start.id)) {
+                return;
+            }
+            visited.add(start.id);
+            const queue: LayoutNode[] = [start];
+            while (queue.length > 0) {
+                const node = queue.shift() as LayoutNode;
+                ordered.push(node);
+                (neighbourIds.get(node.id) ?? [])
+                    .map(neighbourId => nodeById.get(neighbourId))
+                    .filter((neighbour): neighbour is LayoutNode => neighbour 
!== undefined && !visited.has(neighbour.id))
+                    .sort(byDegree)
+                    .forEach(neighbour => {
+                        visited.add(neighbour.id);
+                        queue.push(neighbour);
+                    });
+            }
+        });
+
+        return ordered;
+    }
+
+    /** A circle wide enough to hold every node without them starting on top 
of each other */
+    private getSeedRadius(nodes: LayoutNode[]): number {
+        const maxRadius = nodes.reduce((max, node) => Math.max(max, 
node.radius), 0);
+        const spacing = 2 * maxRadius + this.options.collideDistance + 
this.options.nodeDistance;
+        return Math.max(spacing, (nodes.length * spacing) / (2 * Math.PI));
+    }
+
+    private getDegreeByNodeId(): Map<string, number> {
+        const degrees = new Map<string, number>();
+        const count = (nodeId?: string) => {
+            if (nodeId !== undefined) {
+                degrees.set(nodeId, (degrees.get(nodeId) ?? 0) + 1);
+            }
+        };
+        this.edges.forEach(edge => {
+            count(edge.source?.id);
+            count(edge.target?.id);
+        });
+        return degrees;
+    }
+
+    private getDegree(nodeId?: string): number {
+        return nodeId !== undefined ? this.degreeByNodeId.get(nodeId) ?? 0 : 0;
+    }
+
+    protected startLayout(graph: Graph): void {
+        const { width, height } = graph.getBounds();
+        const cx = width / 2;
+        const cy = height / 2;
+        this.forceSimulation.forceCenter(cx, cy);
+        this.forceSimulation.alpha(1);
+        this.forceSimulation.useForceSimulation(this.nodes, this.edges, 
this.getLinkDistance);
+        this.forceSimulation.restart();
+    }
+
+    protected updateLayout(): void {
+        this.forceSimulation.useForceSimulation(this.nodes, this.edges, 
this.getFixedNodeDistance);
+        this.forceSimulation.alpha(0.2);
+        this.forceSimulation.restart();
+    }
+}
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureHook.tsx
 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureHook.tsx
new file mode 100644
index 00000000..6206f5de
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureHook.tsx
@@ -0,0 +1,30 @@
+import {ROUTES} from "@compass/navigation/Routes";
+import {useNavigate} from "react-router-dom";
+import {useProjectInfoStore} from "@stores/useProjectInfoStore";
+
+export const PROJECT_ID_PREFIX = "project-";
+export const CONSUMER_PREFIX = "consumer-";
+export const PRODUCER_PREFIX = "producer-";
+export const STANDALONE_PREFIX = "standalone-";
+export const STANDALONE_NODE_ID = `${STANDALONE_PREFIX}projects`;
+
+export const NODE_DIAMETER_PROJECT = 50;
+export const NODE_DIAMETER_INOUT = NODE_DIAMETER_PROJECT / 1.5;
+
+export function ArchitectureHook() {
+
+    const [projectInfos] = useProjectInfoStore(s => [s.projectInfos]);
+    const navigate = useNavigate();
+
+    function selectFile(integration: string, fileName: string) {
+        navigate(`${ROUTES.PROJECTS}/${integration}/${fileName}`);
+    }
+
+    function getIntegrationInfo(integrationId: string) {
+        return projectInfos.find(i => i.projectId === integrationId);
+    }
+
+    return {
+        getIntegrationInfo, selectFile,
+    }
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureMenu.tsx
 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureMenu.tsx
new file mode 100644
index 00000000..75bf57d8
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureMenu.tsx
@@ -0,0 +1,98 @@
+import {ContextMenuItem, GraphElement} from '@patternfly/react-topology';
+import * as React from "react";
+import {ROUTES} from "@compass/navigation/Routes";
+import {useNavigate} from "react-router-dom";
+import {ProjectService} from "@services/ProjectService";
+import {FolderOpenIcon, MiddlewareIcon, PlayIcon, StopIcon, TimesIcon} from 
"@patternfly/react-icons";
+import {ArchitectureHook, PROJECT_ID_PREFIX} from "./ArchitectureHook";
+
+const COLOR_INFO = 'var(--pf-t--global--text--color--link--default)';
+const COLOR_DANGER = 'var(--pf-t--global--color--status--danger--default)';
+
+interface Props {
+    element: GraphElement
+}
+
+function ArchitectureMenu(props: Props): React.ReactElement[] {
+
+    // const {setShowModal, setSelectedDomain, deleteDomain} = 
useDashboardStore();
+    const result: React.ReactElement[] = [];
+    const {element} = props;
+    const data = element.getData();
+    const prefix = data.prefix;
+    const empty = data.empty;
+    const {getIntegrationInfo} = ArchitectureHook();
+    const projectId = data.projectId;
+    const domainName = data.domainName;
+    const info = getIntegrationInfo(projectId);
+    const isDevModeRunning = info?.isDevModeRunning ?? false;
+    const isPackagedRunning = info?.isPackagedRunning ?? false;
+    const navigate = useNavigate();
+
+    function start(e?: any) {
+        e?.stopPropagation();
+        ProjectService.startDevModeContainer(projectId, false, false, false);
+    }
+
+    function stop(e?: any) {
+        e?.stopPropagation();
+        ProjectService.deleteDevModeContainer(projectId)
+    }
+
+    function open(e?: any) {
+        e?.stopPropagation();
+        navigate(`${ROUTES.PROJECTS}/${projectId}`);
+    }
+
+    if (element.getType() === "group") {
+        result.push(
+            <ContextMenuItem icon={<MiddlewareIcon color={COLOR_INFO}/>} 
key={`create-project`} onClick={e => {
+                e.stopPropagation();
+                // setSelectedDomain(domainName)
+                // setShowModal('project')
+            }}>
+                {`Create Project`}
+            </ContextMenuItem>
+        )
+        if (empty) {
+            result.push(
+                <ContextMenuItem icon={<TimesIcon color={COLOR_DANGER}/>} 
key={`delete-domain`} onClick={e => {
+                    e.stopPropagation();
+                    // deleteDomain(domainName)
+                }}>
+                    {`Delete Domain`}
+                </ContextMenuItem>
+            )
+        }
+    } else if (element.getType() === "node") {
+        if (prefix === PROJECT_ID_PREFIX) {
+            if (isDevModeRunning) {
+                result.push(
+                    <ContextMenuItem isDanger icon={<StopIcon 
color={COLOR_DANGER}/>} key={"stop"} onClick={e => stop(e)}>
+                        Stop DevMode
+                    </ContextMenuItem>
+                )
+            } else if (!isDevModeRunning && !isPackagedRunning){
+                result.push(
+                    <ContextMenuItem icon={<PlayIcon color={COLOR_INFO}/>} 
key={"start"} onClick={e => start(e)}>
+                        Start DevMode
+                    </ContextMenuItem>
+                )
+            }
+            result.push(
+                <ContextMenuItem icon={<FolderOpenIcon color={COLOR_INFO}/>} 
key={"open"} onClick={e => open(e)}>
+                    Open Project
+                </ContextMenuItem>
+            )
+        }
+    } else {
+
+    }
+    return result
+}
+
+export function ArchitectureMenus(element: GraphElement) {
+    return (
+        [<ArchitectureMenu key={1} element={element}/>]
+    )
+}
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureNode.tsx
 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureNode.tsx
new file mode 100644
index 00000000..c6bdf0fe
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureNode.tsx
@@ -0,0 +1,98 @@
+import * as React from 'react';
+import {DefaultNode, NodeLabel, observer, WithDndDropProps, WithDragNodeProps, 
WithSelectionProps} from '@patternfly/react-topology';
+import {CONSUMER_PREFIX, PRODUCER_PREFIX, PROJECT_ID_PREFIX, 
STANDALONE_NODE_ID} from "./ArchitectureHook";
+import {useNavigate} from "react-router-dom";
+import {ROUTES} from "@compass/navigation/Routes";
+import {CamelUi} from "@designer/utils/CamelUi";
+import {CamelElement} from "@core/model/IntegrationDefinition";
+import {runInAction} from "mobx";
+import {Apps} from "@carbon/icons-react";
+
+function getIcon(data: any) {
+    if (data.prefix === PROJECT_ID_PREFIX ) {
+        return (
+            <g>
+                <g transform={`translate(13, 13) scale(1.5)`}>
+                    <Apps/>
+                </g>
+            </g>
+        )
+    } else if (data.prefix === CONSUMER_PREFIX || data.prefix === 
PRODUCER_PREFIX ) {
+        const step = new CamelElement("ToDefinition");
+        (step as any).uri = data.component?.name;
+        return (
+            <g transform={`translate(7, 7) scale(0.6)`}>
+                {CamelUi.getConnectionIcon(step)}
+            </g>
+        )
+    }
+}
+
+export const ArchitectureNode: React.FC<any & WithSelectionProps & 
WithDragNodeProps & WithDndDropProps> = observer(
+    React.forwardRef((props: any, ref) => {
+        const {element, onContextMenu, contextMenuOpen, dragNodeRef, ...rest} 
= props;
+        const navigate = useNavigate();
+        const data = element.getData();
+        const statusTooltip = data.statusTooltip;
+        const prefix = data.prefix;
+        const projectId = data.projectId;
+        const showStats = data.showStats;
+        const isRunning = data.isRunning ?? false;
+        const runningClassName = isRunning ? 'up' : 'down';
+        const typeClassName = `${prefix}${runningClassName}`;
+        const statsClassName = showStats && isRunning && data.prefix === 
PROJECT_ID_PREFIX ? 'integration-node-stats' : ''
+        const hideContextMenuKebab = 
![PROJECT_ID_PREFIX].includes(data.prefix);
+        const {width, height} = element.getDimensions();
+
+        const label = element.getLabel();
+        if (label?.length > 30) {
+            runInAction(() => {
+                element.setLabel(label?.substring(0, 20) + '...');
+            });
+        }
+
+        let className =  `integration-node 
integration-node-${runningClassName} ${statsClassName} ${typeClassName}`
+        if (element.id === STANDALONE_NODE_ID) {
+            className = "node-transparent"
+        }
+
+        return (
+            <g onDoubleClick={event => {
+                event.stopPropagation();
+                // The standalone anchor belongs to no project, so there is 
nothing to open
+                if (projectId !== undefined) {
+                    navigate(`${ROUTES.PROJECTS}/${projectId}`);
+                }
+            }}>
+                <DefaultNode dragNodeRef={dragNodeRef}
+                             showStatusBackground={false}
+                             showStatusDecorator
+                             statusDecoratorTooltip={statusTooltip}
+                             className={className}
+                             scaleLabel={true}
+                             element={element}
+                             onContextMenu={onContextMenu}
+                             contextMenuOpen={contextMenuOpen}
+                             hideContextMenuKebab={true}
+                             showLabel={false}
+                             onStatusDecoratorClick={_ => {}}
+                             {...rest}
+                >
+                    {getIcon(data)}
+                    <NodeLabel
+                        x={width / 2}
+                        y={height - (hideContextMenuKebab ? 5 : -4)}
+                        paddingX={8}
+                        paddingY={4}
+                        className={"pf-topology__node__label"}
+                        onContextMenu={onContextMenu}
+                        contextMenuOpen={contextMenuOpen}
+                        hideContextMenuKebab={true}
+                    >
+                        {element.getLabel()}
+                    </NodeLabel>
+                </DefaultNode>
+            </g>
+        )
+    })
+);
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureRefresher.tsx
 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureRefresher.tsx
new file mode 100644
index 00000000..f56d5f1f
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureRefresher.tsx
@@ -0,0 +1,69 @@
+import * as React from 'react';
+import {useEffect, useState} from 'react';
+import './Architecture.css';
+import {useContainerStatusesStore} from "@stores/ContainerStatusesStore";
+import {useStatusesStore} from "@stores/ProjectStore";
+import {useDataPolling} from "@shared/polling/useDataPolling";
+import {ProjectService} from "@services/ProjectService";
+import {useDeploymentStatusesStore} from "@stores/DeploymentStatusesStore";
+
+export function ArchitectureRefresher() {
+
+    const [count, setCount] = useState<number>();
+    const [map, setMap] = useState<{projectId: string, state:string}[]>([]);
+
+    const containers = useContainerStatusesStore(s => s.containers);
+    const fetchDeployments = useDeploymentStatusesStore(s => 
s.fetchDeployments);
+
+    useDataPolling('ArchitectureRefresherRefresherRuntime', refreshRuntime, 
3000);
+    useDataPolling('ArchitectureRefresherRefresherDesign', refreshDesign, 
10000);
+
+    function refreshDesign() {
+        ProjectService.refreshProjects();
+    }
+
+    function refreshRuntime() {
+        ProjectService.refreshAllContainerStatuses();
+        ProjectService.refreshAllCamelProcessorStatuses();
+        ProjectService.refreshAllCamelConsumerStatuses();
+        ProjectService.refreshAllCamelContextStatuses();
+        ProjectService.refreshAllCamelRouteStatuses();
+        fetchDeployments();
+    }
+
+    useEffect(() => {
+        let needRefresh = count === undefined;
+        const containersCount = containers.length;
+        if (containersCount !== count) {
+            setCount(containersCount);
+            needRefresh = true;
+        } else {
+            const containerMap = containers
+                .filter(c => ['devmode', 'packaged'].includes(c.type))
+                .sort((a, b) => a.projectId.localeCompare(b.projectId))
+                .map(c => ({projectId: c.projectId, state: c.state}));
+            if (!mapsEqualUnordered(containerMap, map)) {
+                setMap(containerMap);
+                needRefresh = true;
+            }
+        }
+    }, [containers]);
+
+
+    function mapsEqualUnordered(a: {projectId: string, state:string}[], b: 
{projectId: string, state:string}[]): boolean {
+        const sortById = (arr: {projectId: string, state:string}[]) =>
+            [...arr].sort((x, y) => x.projectId.localeCompare(y.projectId));
+
+        const sortedA = sortById(a);
+        const sortedB = sortById(b);
+
+        return sortedA.length === sortedB.length &&
+            sortedA.every((item, i) =>
+                item.projectId === sortedB[i].projectId && item.state === 
sortedB[i].state
+            );
+    }
+
+    return (
+        <>{}</>
+    );
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureTab.tsx
 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureTab.tsx
new file mode 100644
index 00000000..70ba37eb
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/architecture/ArchitectureTab.tsx
@@ -0,0 +1,29 @@
+import * as React from 'react';
+import './Architecture.css';
+import {TopologyControlBar, TopologyView, VisualizationProvider, 
VisualizationSurface,} from '@patternfly/react-topology';
+import {ArchitectureController} from "./ArchitectureController";
+import {ArchitectureRefresher} from "../architecture/ArchitectureRefresher";
+import {ErrorBoundaryWrapper} from "@shared/ui/ErrorBoundaryWrapper";
+import {ProjectsToolbar} from "../ProjectsToolbar";
+
+export function ArchitectureTab() {
+
+    const { controller, clearAllSelection, controlButtons} = 
ArchitectureController();
+
+    return (
+        <div className="projects-architecture-page">
+            <ErrorBoundaryWrapper key='projects-architecture-page' 
onError={error => console.error(error)}>
+                <ProjectsToolbar type={"simple"}/>
+                <VisualizationProvider controller={controller}>
+                    <TopologyView
+                        className="projects-architecture-panel"
+                        controlBar={<TopologyControlBar 
controlButtons={controlButtons} />}
+                    >
+                        <VisualizationSurface />
+                    </TopologyView>
+                </VisualizationProvider>
+                <ArchitectureRefresher key={"000"}/>
+            </ErrorBoundaryWrapper>
+        </div>
+    )
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectStatusLabel.tsx 
b/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectStatusLabel.tsx
new file mode 100644
index 00000000..a84462b3
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectStatusLabel.tsx
@@ -0,0 +1,73 @@
+/*
+ * 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, {ReactElement} from 'react';
+import {BuildIcon, CogIcon, CubesIcon, DevIcon, InProgressIcon, LockIcon, 
PackageIcon} from '@patternfly/react-icons';
+import {Label} from "@patternfly/react-core";
+import {ContainerType} from "@models/ProjectModels";
+import {useContainerStatusesStore} from "@stores/ContainerStatusesStore";
+
+interface Props {
+    projectId: string
+}
+
+export function ProjectStatusLabel(props: Props) {
+
+    const {projectId} = props;
+    const containers = useContainerStatusesStore(s => s.containers);
+    const camelContainer = containers?.filter(c => c.projectId === projectId 
&& ['devmode', 'packaged'].includes(c.type)).at(0);
+    const isCamelRunning = camelContainer && camelContainer?.state === 
'running';
+
+    const buildContainer = containers?.filter(c => c.projectId === projectId 
&& ['build'].includes(c.type)).at(0);
+    const isBuildRunning = buildContainer && buildContainer?.state === 
'running';
+    const hasContainers = containers?.filter(c => c.projectId === 
projectId).length > 0;
+    const isRunning = containers?.filter(c => c.projectId === projectId && 
c.state === 'running').length > 0;
+
+    const colorRunBack = 'var(--pf-t--color--green--30)';
+    const colorRun = 'var(--pf-t--global--color--status--success--200)';
+    const colorControl = 'var(--pf-v6-c-button--m-control--Color)';
+    const colorBack = isRunning ? colorRunBack : colorControl;
+    const variant = hasContainers ? 'filled' : 'outline';
+    const firstIcon = (isRunning || isBuildRunning)
+        ? <CogIcon color={colorRun} className={'rotated-run-forward'}/>
+        : <InProgressIcon/>;
+
+    const typeIconColor = isRunning ? colorRun : colorControl;
+    const iconMap: Record<ContainerType, ReactElement | undefined> = {
+        devmode: <DevIcon color={typeIconColor}/>,
+        packaged: <PackageIcon color={typeIconColor}/>,
+        internal: <LockIcon color={typeIconColor}/>,
+        build: <BuildIcon color={typeIconColor}/>,
+        unknown: undefined,
+    };
+
+    const type: ContainerType = camelContainer?.type || buildContainer?.type 
|| 'unknown';
+    const typeIcon = iconMap[type];
+
+    if (hasContainers) {
+        return (
+            <Label color={isRunning ? 'green' : 'grey'} isCompact 
variant={variant} style={{padding: '4px'}} >
+                <div style={{display: 'flex', justifyContent: 'space-between', 
alignItems: 'center', gap: '6px', width: '100%'}}>
+                    {firstIcon}
+                    {typeIcon ? typeIcon : <CubesIcon color={typeIconColor}/>}
+                </div>
+            </Label>
+        )
+    } else {
+        return undefined
+    }
+}
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTab.tsx 
b/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTab.tsx
new file mode 100644
index 00000000..74779265
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTab.tsx
@@ -0,0 +1,121 @@
+import React, {useEffect, useState} from 'react';
+import {Bullseye, EmptyState, EmptyStateVariant, ProgressStep, 
ProgressStepper} from '@patternfly/react-core';
+import {InnerScrollContainer, OuterScrollContainer, Table, Tbody, Td, Th, 
Thead, Tr} from '@patternfly/react-table';
+import {SearchIcon} from '@patternfly/react-icons';
+import {shallow} from "zustand/shallow";
+import {useProjectsStore, useProjectStore} from "@stores/ProjectStore";
+import {useSearchStore} from "@stores/SearchStore";
+import {ComplexityProject} from "@models/ComplexityModels";
+import {useActivityStore} from "@stores/ActivityStore";
+import {useDataPolling} from "@shared/polling/useDataPolling";
+import {ComplexityApi} from "@api/ComplexityApi";
+import {ProjectType} from "@models/ProjectModels";
+import {ProjectsToolbar} from "../ProjectsToolbar";
+import ProjectsTableRow from "../table/ProjectsTableRow";
+import {CreateProjectModal} from "@page-project/files/CreateProjectModal";
+import {DeleteProjectModal} from "../DeleteProjectModal";
+
+export function ProjectsTab() {
+
+    const [projects, projectsCommited, labels, selectedLabels] =
+        useProjectsStore((s) => [s.projects, s.projectsCommited, 
s.projectLabels, s.selectedLabels], shallow)
+    const [operation] = useProjectStore((s) => [s.operation], shallow)
+    const [search, searchResults] = useSearchStore((s) => [s.search, 
s.searchResults], shallow)
+    const [complexities, setComplexities] = useState<ComplexityProject[]>([]);
+    const {projectsActivities, fetchProjectsActivities} = useActivityStore();
+
+    useEffect(() => refreshActivity(), []);
+    useDataPolling('ProjectsTab', refreshActivity, 7000);
+
+    function refreshActivity() {
+        fetchProjectsActivities();
+        ComplexityApi.getComplexityProjects(complexities => {
+            setComplexities(complexities);
+        })
+    }
+
+    function getEmptyState() {
+        return (
+            <Tr>
+                <Td colSpan={8}>
+                    <Bullseye>
+                        <EmptyState variant={EmptyStateVariant.sm} 
titleText="No results found" icon={SearchIcon} headingLevel="h2"/>
+                    </Bullseye>
+                </Td>
+            </Tr>
+        )
+    }
+
+    function getProjectsTable() {
+        let projs = projects
+            .filter(p => p.type === ProjectType.integration)
+            .filter(p => searchResults.map(s => 
s.projectId).includes(p.projectId) || search === '');
+        if (selectedLabels.length > 0) {
+            projs = projs.filter(p => {
+                const labs: string[] = labels[p.projectId] !== undefined && 
Array.isArray(labels[p.projectId]) ? labels[p.projectId] : [];
+                return labs.some(l => selectedLabels.includes(l));
+            });
+        }
+        return (
+            <div style={{display: 'flex', flexDirection: 'column', height: 
'100%'}}>
+                <ProjectsToolbar type={"full"}/>
+                <OuterScrollContainer>
+                    <InnerScrollContainer>
+                        <Table aria-label="Projects" variant='compact' 
isStickyHeader>
+                            <Thead>
+                                <Tr>
+                                    <Th key='icon' screenReaderText='pass' 
modifier='fitContent'/>
+                                    <Th key='projectId'>Name</Th>
+                                    <Th key='name'>Description</Th>
+                                    <Th key='size' modifier='fitContent' 
textCenter>Size</Th>
+                                    <Th key='routes' modifier='fitContent' 
textCenter>Routes</Th>
+                                    <Th key='timeline' modifier={"fitContent"}>
+                                        <ProgressStepper isCenterAligned 
className={"projects-table-header-progress-stepper"}>
+                                            <ProgressStep id="commited" 
titleId="commited">
+                                                <div style={{textWrap: 
'nowrap'}}>Commited</div>
+                                            </ProgressStep>
+                                            <ProgressStep id="saved" 
titleId="saved">
+                                                <div style={{textWrap: 
'nowrap'}}>Saved</div>
+                                            </ProgressStep>
+                                        </ProgressStepper>
+                                    </Th>
+                                    <Th key='acivity' modifier={"fitContent"} 
textCenter>Active Users</Th>
+                                    <Th key='status' modifier={"fitContent"} 
textCenter>Status</Th>
+                                    <Th key='action' modifier={"fitContent"} 
aria-label='topology-modal'></Th>
+                                </Tr>
+                            </Thead>
+                            <Tbody>
+                                {projs.map(project => {
+                                    const complexity = complexities.filter(c 
=> c.projectId === project.projectId).at(0) || new 
ComplexityProject({projectId: project.projectId});
+                                    const activity = 
projectsActivities?.[project.projectId];
+                                    const activeUsers: string [] = (activity 
&& Array.isArray(activity)) ? activity : [];
+                                    const projectCommited = 
projectsCommited.find(pc => pc.projectId === project.projectId);
+                                    return (
+                                        <ProjectsTableRow
+                                            key={project.projectId}
+                                            project={project}
+                                            projectCommited={projectCommited}
+                                            complexity={complexity}
+                                            activeUsers={activeUsers}
+                                            
labels={Array.isArray(labels?.[project.projectId]) ? 
labels?.[project.projectId] : []}
+                                            selectedLabels={selectedLabels}
+                                        />
+                                    )
+                                })}
+                                {projs.length === 0 && getEmptyState()}
+                            </Tbody>
+                        </Table>
+                    </InnerScrollContainer>
+                </OuterScrollContainer>
+            </div>
+        )
+    }
+
+    return (
+        <div className="right-panel-card">
+            {getProjectsTable()}
+            {["create", "copy"].includes(operation) && <CreateProjectModal/>}
+            {["delete"].includes(operation) && <DeleteProjectModal/>}
+        </div>
+    )
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTableRow.tsx 
b/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTableRow.tsx
new file mode 100644
index 00000000..ad738c75
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTableRow.tsx
@@ -0,0 +1,152 @@
+import React from 'react';
+import {Badge, Button, Flex, FlexItem, Tooltip} from '@patternfly/react-core';
+import {Td, Tr} from "@patternfly/react-table";
+import {shallow} from "zustand/shallow";
+import {useNavigate} from "react-router-dom";
+import FileSaver from "file-saver";
+import TimeAgo from 'javascript-time-ago'
+import en from 'javascript-time-ago/locale/en'
+import {ROUTES} from "@compass/navigation/Routes";
+import {ProjectZipApi} from "../ProjectZipApi";
+import {ProjectStatusLabel} from "./ProjectStatusLabel";
+import {CamelIcon, OpenApiIcon} from "@designer/icons/KaravanIcons";
+import {CogIcon, CopyIcon, DownloadIcon, TimesCircleIcon} from 
"@patternfly/react-icons";
+import {BUILD_IN_PROJECTS, Project, ProjectCommited} from 
"@models/ProjectModels";
+import {ComplexityProject} from "@models/ComplexityModels";
+import {PROJECT_WITH_NO_LABELS, useProjectStore} from "@stores/ProjectStore";
+import {ProjectsTableRowTimeLine} from "../table/ProjectsTableRowTimeLine";
+import {ProjectsTableRowActivity} from "../table/ProjectsTableRowActivity";
+import {useAppConfig} from "@compass/useConfig";
+import {ProjectLabelSize} from "@shared/ui/ProjectLabelSize";
+import {ProjectLabelRoutes} from "@shared/ui/ProjectLabelRoutes";
+
+TimeAgo.addDefaultLocale(en)
+
+interface Props {
+    project: Project
+    projectCommited?: ProjectCommited
+    complexity: ComplexityProject
+    activeUsers: string[]
+    labels: string[]
+    selectedLabels: string[]
+}
+
+function ProjectsTableRow(props: Props) {
+
+    const {project, complexity, activeUsers, labels, selectedLabels, 
projectCommited} = props;
+    const {isDev} = useAppConfig();
+    const [setProject] = useProjectStore((state) => [state.setProject], 
shallow);
+    const navigate = useNavigate();
+    const form = new Intl.NumberFormat('en-US');
+
+    const isBuildIn = BUILD_IN_PROJECTS.includes(project.projectId);
+    let icon = <CogIcon/>;
+    if (!isBuildIn) {
+        if (complexity.exposesOpenApi) {
+            icon = <OpenApiIcon width={20} height={20}/>;
+        } else {
+            icon = CamelIcon(undefined, 16, 16);
+        }
+    }
+
+    function downloadProject(projectId: string) {
+        ProjectZipApi.downloadZip(projectId, data => {
+            FileSaver.saveAs(data, projectId + ".zip");
+        });
+    }
+
+    return (
+        <Tr key={project.projectId} className={"projects-table-row"}>
+            <Td modifier='fitContent' style={{paddingInlineEnd: 0, 
paddingInlineStart: '6px'}}>
+                <div style={{display: "flex", justifyContent: "center"}}>
+                    {icon}
+                </div>
+            </Td>
+            <Td>
+                <Button style={{padding: '6px', paddingInlineStart: 0}} 
variant={"link"} onClick={e => {
+                    navigate(`${ROUTES.PROJECTS}/${project.projectId}`);
+                }}>
+                    {project.projectId}
+                </Button>
+            </Td>
+            <Td>
+                <div style={{display: 'flex', flexDirection: 'column', 
alignItems: 'start', justifyContent: 'start', gap: '3px'}}>
+                    <div>
+                        {project.name}
+                    </div>
+                    {labels.length > 0 &&
+                        <div style={{display: 'flex', flexDirection: 'row', 
gap: '3px'}}>
+                            {labels.filter(l => l !== 
PROJECT_WITH_NO_LABELS).map((label) => (
+                                <Badge key={label} 
isRead={!selectedLabels.includes(label)} style={{fontWeight: 'normal', cursor: 
'pointer'}}>
+                                    {label}
+                                </Badge>
+                            ))}
+                        </div>
+                    }
+                </div>
+            </Td>
+            <Td modifier={"fitContent"} style={{textAlign: "right"}}>
+                <ProjectLabelSize complexity={complexity} full={true}/>
+            </Td>
+            <Td modifier={"fitContent"} style={{textAlign: "right"}}>
+                <ProjectLabelRoutes complexity={complexity} full={true}/>
+            </Td>
+            <Td modifier={"nowrap"} textCenter>
+                <ProjectsTableRowTimeLine project={project} 
projectCommited={projectCommited}/>
+            </Td>
+            <Td noPadding>
+                {!isBuildIn && <ProjectsTableRowActivity 
activeUsers={activeUsers}/>}
+            </Td>
+            <Td noPadding>
+                {!isBuildIn && <ProjectStatusLabel 
projectId={project.projectId}/>}
+            </Td>
+            <Td modifier={"fitContent"}>
+                <Flex direction={{default: "row"}} justifyContent={{default: 
"justifyContentFlexEnd"}} spaceItems={{default: 'spaceItemsNone'}} 
flexWrap={{default: 'nowrap'}}>
+                    {!isBuildIn &&
+                        <FlexItem>
+                            <Tooltip content={"Delete"} position={"bottom"}>
+                                <Button className="dev-action-button"
+                                        isDisabled={!isDev}
+                                        isInline={!isDev}
+                                        variant={"link"}
+                                        isDanger
+                                        icon={<TimesCircleIcon/>}
+                                        onClick={e => {
+                                    setProject(project, "delete");
+                                }}></Button>
+                            </Tooltip>
+                        </FlexItem>
+                    }
+                    {!isBuildIn &&
+                        <FlexItem>
+                            <Tooltip content={"Copy"} position={"bottom"}>
+                                <Button className="dev-action-button"
+                                        isDisabled={!isDev}
+                                        isInline={!isDev}
+                                        variant={"link"}
+                                        icon={<CopyIcon/>}
+                                        onClick={e => {
+                                            setProject(project, "copy");
+                                        }}></Button>
+                            </Tooltip>
+                        </FlexItem>
+                    }
+                    <FlexItem>
+                        <Tooltip content={"Export"} position={"bottom-end"}>
+                            <Button className="dev-action-button"
+                                    isDisabled={!isDev}
+                                    isInline={!isDev}
+                                    variant={"link"}
+                                    icon={<DownloadIcon/>}
+                                    onClick={e => {
+                                        downloadProject(project.projectId);
+                                    }}></Button>
+                        </Tooltip>
+                    </FlexItem>
+                </Flex>
+            </Td>
+        </Tr>
+    )
+}
+
+export default ProjectsTableRow
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTableRowActivity.css
 
b/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTableRowActivity.css
new file mode 100644
index 00000000..45534886
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTableRowActivity.css
@@ -0,0 +1,9 @@
+.active-users {
+    .pf-v6-c-label-group__main {
+        .pf-v6-c-label-group__list {
+            display: flex;
+            flex-direction: column;
+            gap: 1px;
+        }
+    }
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTableRowActivity.tsx
 
b/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTableRowActivity.tsx
new file mode 100644
index 00000000..4178a560
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTableRowActivity.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import {Label, LabelGroup} from "@patternfly/react-core";
+import "./ProjectsTableRowActivity.css"
+
+interface Props {
+    activeUsers: string[]
+}
+
+export function ProjectsTableRowActivity (props: Props) {
+
+    const {activeUsers} = props;
+
+    return (
+        <LabelGroup className='active-users' numLabels={1}>
+            {activeUsers.length > 0 && activeUsers.slice(0, 5).map(user =>
+                <Label key={user} color='blue' isCompact>{user}</Label>
+            )}
+        </LabelGroup>
+    )
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTableRowTimeLine.css
 
b/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTableRowTimeLine.css
new file mode 100644
index 00000000..dc50f119
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTableRowTimeLine.css
@@ -0,0 +1,43 @@
+.projects-table-row {
+    vertical-align: middle;
+}
+
+.projects-table-header-progress-stepper {
+    .pf-v6-c-progress-stepper__step-main {
+        margin: 0;
+    }
+    .pf-v6-c-progress-stepper__step-connector {
+        visibility: hidden;
+        height: 0;
+    }
+    .pf-v6-c-progress-stepper__step-title {
+        font-size: var(--pf-v6-c-table--cell--FontSize);
+        font-weight: var(--pf-v6-c-table--cell--FontWeight);
+        line-height: var(--pf-v6-c-table--cell--LineHeight);
+        color: var(--pf-v6-c-table--cell--Color);
+        text-overflow: var(--pf-v6-c-table--cell--TextOverflow);
+    }
+}
+
+.projects-table-progress-stepper-wrapper {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    .commit-label {
+        .pf-v6-c-label__text {
+            font-size: var(--pf-t--global--font--size--xs);
+        }
+    }
+}
+
+.projects-table-progress-stepper {
+    min-width: 200px;
+    .pf-v6-c-progress-stepper__step-main {
+        margin: 0;
+    }
+    .pf-v6-c-progress-stepper__step-title {
+        font-size: var(--pf-t--global--font--size--xs);
+        font-weight: var(--pf-v6-c-progress-stepper__step-title--FontWeight);
+        color: var(--pf-v6-c-progress-stepper__step-title--Color);
+    }
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTableRowTimeLine.tsx
 
b/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTableRowTimeLine.tsx
new file mode 100644
index 00000000..0bf84a81
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/table/ProjectsTableRowTimeLine.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import {Label, ProgressStep, ProgressStepper} from '@patternfly/react-core';
+import TimeAgo from 'javascript-time-ago'
+import en from 'javascript-time-ago/locale/en'
+import './ProjectsTableRowTimeLine.css'
+import {CheckCircleIcon} from "@patternfly/react-icons";
+import {InProgress} from "@carbon/icons-react";
+import {Project, ProjectCommited} from "@models/ProjectModels";
+import timeAgo from "@shared/timeAgo";
+
+TimeAgo.addDefaultLocale(en)
+
+interface Props {
+    project: Project
+    projectCommited?: ProjectCommited
+}
+
+export function ProjectsTableRowTimeLine(props: Props) {
+
+    const {project, projectCommited} = props;
+
+    const commitTimeStamp = projectCommited !== undefined ? 
projectCommited.lastCommitTimestamp : 0;
+    const commited = commitTimeStamp !== 0;
+    const lastUpdate = project.lastUpdate;
+    const synced = lastUpdate === commitTimeStamp;
+    const commitIcon = commited ? <CheckCircleIcon/> : undefined;
+    const commitLabel = commited ? timeAgo.format(new Date(commitTimeStamp)) : 
'No commits yet';
+    const savedIcon = synced ? <CheckCircleIcon/> : <InProgress/>;
+    const savedLabel = synced ? '' : timeAgo.format(new Date(lastUpdate));
+    return (
+        <div className="projects-table-progress-stepper-wrapper">
+            <ProgressStepper isCenterAligned 
className={"projects-table-progress-stepper"}>
+                <ProgressStep icon={commitIcon} variant={commited ? "success" 
: "default"} id="commit" titleId="commit" aria-label="commit">
+                    {!synced && <div style={{textWrap: 
'nowrap'}}>{commitLabel}</div>}
+                </ProgressStep>
+                <ProgressStep icon={savedIcon} isCurrent={!synced} 
variant={synced ? "success" : "default"} id="saved" titleId="saved" 
aria-label="saved">
+                    <div style={{textWrap: 'nowrap'}}>{savedLabel}</div>
+                </ProgressStep>
+            </ProgressStepper>
+            {synced &&
+                <Label color={"green"} isCompact className={"commit-label"}>
+                    {commitLabel}
+                </Label>
+            }
+        </div>
+    )
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/ui/page-projects/useCreateProjectFormUtil.tsx 
b/karavan-app/src/main/webui/src/ui/page-projects/useCreateProjectFormUtil.tsx
index 3acc5a5b..3f24a1a0 100644
--- 
a/karavan-app/src/main/webui/src/ui/page-projects/useCreateProjectFormUtil.tsx
+++ 
b/karavan-app/src/main/webui/src/ui/page-projects/useCreateProjectFormUtil.tsx
@@ -28,7 +28,7 @@ import {SimpleSelectOption} from 
"@patternfly/react-templates/src/components/Sel
 import {MonacoEditor} from "@shared/MonacoEditor";
 import {useAppConfig} from "@compass/useConfig";
 
-export function useFormUtil(formContext: UseFormReturn<any>) {
+export function useCreateProjectFormUtil(formContext: UseFormReturn<any>) {
 
     const [showPassword, setShowPassword] = useState<boolean>(false);
     const {isDev} = useAppConfig();

Reply via email to