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

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

commit 2d790e4ec3d3f322d40652b6d097abac561b4674
Author: Marat Gubaidullin <[email protected]>
AuthorDate: Fri Feb 27 18:52:44 2026 -0500

    Front-end Projects for 4.18.0
---
 .../src/karavan/features/projects/Complexity.css   |  42 ++++++
 .../karavan/features/projects/ComplexityApi.tsx    |  40 ++++++
 .../karavan/features/projects/ComplexityModels.ts  |  72 ++++++++++
 .../features/projects/CreateProjectModal.tsx       | 150 +++++++++++++++++++++
 .../features/projects/DeleteProjectModal.tsx       |  69 ++++++++++
 .../features/projects/ProjectStatusLabel.tsx       |  80 +++++++++++
 .../karavan/features/projects/ProjectZipApi.tsx    |  39 ++++++
 .../src/karavan/features/projects/ProjectsPage.tsx |  88 ++++++++++++
 .../src/karavan/features/projects/ProjectsTab.tsx  | 146 ++++++++++++++++++++
 .../karavan/features/projects/ProjectsTableRow.tsx | 117 ++++++++++++++++
 .../features/projects/ProjectsTableRowActivity.tsx |  19 +++
 .../projects/ProjectsTableRowComplexity.tsx        |  48 +++++++
 .../features/projects/ProjectsTableRowTimeLine.css |  43 ++++++
 .../features/projects/ProjectsTableRowTimeLine.tsx |  48 +++++++
 .../karavan/features/projects/ProjectsToolbar.tsx  | 105 +++++++++++++++
 .../karavan/features/projects/SettingsToolbar.tsx  |  59 ++++++++
 .../features/projects/UploadProjectModal.tsx       |  99 ++++++++++++++
 17 files changed, 1264 insertions(+)

diff --git 
a/karavan-app/src/main/webui/src/karavan/features/projects/Complexity.css 
b/karavan-app/src/main/webui/src/karavan/features/projects/Complexity.css
new file mode 100644
index 00000000..9ff97868
--- /dev/null
+++ b/karavan-app/src/main/webui/src/karavan/features/projects/Complexity.css
@@ -0,0 +1,42 @@
+.karavan .complexity .top-icon {
+    width: 1em;
+    height: 1em;
+    vertical-align: middle;
+}
+.karavan .complexity svg {
+    vertical-align: middle;
+}
+
+.karavan .complexity .complexity-label {
+    .pf-v6-c-label__icon {
+        margin-right: 2px;
+    }
+}
+
+.karavan .files-table {
+    .icon {
+        height: 16px;
+        width: 16px;
+    }
+
+    .icon-docker {
+        fill: #0db7ed;
+    }
+}
+
+.validation-icon {
+    height: 16px;
+    width: 16px;
+}
+.validation-icon-danger {
+    color: var(--pf-t--global--icon--color--status--danger--default);
+    fill: var(--pf-t--global--icon--color--status--danger--default);
+}
+.validation-icon-pending {
+    fill: var(--pf-t--global--color--brand--default);
+    color: var(--pf-t--global--color--brand--default);
+}
+
+.rotated-run-forward {
+    animation: rotate-icon-forward 3s linear infinite
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/karavan/features/projects/ComplexityApi.tsx 
b/karavan-app/src/main/webui/src/karavan/features/projects/ComplexityApi.tsx
new file mode 100644
index 00000000..6172a8c5
--- /dev/null
+++ b/karavan-app/src/main/webui/src/karavan/features/projects/ComplexityApi.tsx
@@ -0,0 +1,40 @@
+import axios from "axios";
+import {ErrorEventBus} from "@bus/ErrorEventBus";
+import {ComplexityProject} from "./ComplexityModels";
+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 ComplexityApi {
+
+    static async getComplexityProject(projectId: string, after: (complexity?: 
ComplexityProject) => void) {
+        instance.get('/ui/complexity/' + projectId)
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                } else {
+                    after(undefined);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+            after(undefined);
+        });
+    }
+
+    static async getComplexityProjects(after: (complexities: 
ComplexityProject[]) => void) {
+        instance.get('/ui/complexity')
+            .then(res => {
+                if (res.status === 200) {
+                    const c: ComplexityProject[] = Array.isArray(res.data) ? 
res.data?.map(x => new ComplexityProject(x)) : [];
+                    after(c);
+                } else {
+                    after([]);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+            after([]);
+        });
+    }
+}
diff --git 
a/karavan-app/src/main/webui/src/karavan/features/projects/ComplexityModels.ts 
b/karavan-app/src/main/webui/src/karavan/features/projects/ComplexityModels.ts
new file mode 100644
index 00000000..b8e166fd
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/karavan/features/projects/ComplexityModels.ts
@@ -0,0 +1,72 @@
+export type ComplexityType = 'easy' | 'normal' | 'complex'
+
+export class ComplexityRoute {
+    routeId: string = ''
+    nodePrefixId: string = ''
+    fileName: string = ''
+    consumers: any = [];
+    producers: any[] = [];
+    routeTemplateRef: string;
+    isTemplated: boolean;
+}
+
+export class ComplexityFile {
+    fileName: string = '';
+    error: string = '';
+    type: string = '';
+    chars: number = 0;
+    routes: number = 0;
+    beans: number = 0;
+    rests: number = 0;
+    complexity: ComplexityType = 'easy';
+    complexityLines: ComplexityType = 'easy';
+    complexityRoutes: ComplexityType = 'easy';
+    complexityRests: ComplexityType = 'easy';
+    complexityBeans: ComplexityType = 'easy';
+    complexityProcessors: ComplexityType = 'easy';
+    complexityComponentsInt: ComplexityType = 'easy';
+    complexityComponentsExt: ComplexityType = 'easy';
+    complexityKamelets: ComplexityType = 'easy';
+    processors: any = {};
+    componentsInt: any = {};
+    componentsExt: any = {};
+    kamelets: any = {};
+    generated: boolean = false;
+
+    public constructor(init?: Partial<ComplexityFile>) {
+        Object.assign(this, init);
+    }
+}
+
+export class ComplexityProject {
+    projectId: string = '';
+    lastUpdateDate: number = 0;
+    complexityRoute: ComplexityType = 'easy';
+    complexityRest: ComplexityType = 'easy';
+    complexityJava: ComplexityType = 'easy';
+    complexityFiles: ComplexityType = 'easy';
+    files: ComplexityFile[] = []
+    routes: ComplexityRoute[] = []
+    dependencies: string[] = []
+    rests: number = 0;
+    exposesOpenApi: boolean = false;
+    type: string;
+
+    public constructor(init?: Partial<ComplexityProject>) {
+        Object.assign(this, init);
+    }
+}
+
+export function getComplexityColor(complexity: ComplexityType) {
+    return complexity === 'easy' ? 'green' : (complexity === 'complex' ? 
'orange' : 'blue');
+}
+
+export function getMaxComplexity(complexities: (ComplexityType) []): 
ComplexityType {
+    if (complexities.filter(c => c === 'complex').length > 0) {
+        return 'complex'
+    } else if (complexities.filter(c => c === 'normal').length > 0) {
+        return 'normal'
+    } else {
+        return 'easy'
+    }
+}
diff --git 
a/karavan-app/src/main/webui/src/karavan/features/projects/CreateProjectModal.tsx
 
b/karavan-app/src/main/webui/src/karavan/features/projects/CreateProjectModal.tsx
new file mode 100644
index 00000000..0d8c67d0
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/karavan/features/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 "@util/StringUtils";
+import {EventBus} from "@features/project/designer/utils/EventBus";
+import {SubmitHandler, useForm} from "react-hook-form";
+import {useFormUtil} from "@util/useFormUtil";
+import {KaravanApi} from "@api/KaravanApi";
+import {AxiosResponse} from "axios";
+import {shallow} from "zustand/shallow";
+import {useNavigate} from "react-router-dom";
+import {ROUTES} from "@app/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/karavan/features/projects/DeleteProjectModal.tsx
 
b/karavan-app/src/main/webui/src/karavan/features/projects/DeleteProjectModal.tsx
new file mode 100644
index 00000000..6fca6e12
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/karavan/features/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"})
+    }
+
+    function confirmAndCloseModal() {
+        ProjectService.deleteProject(project, deleteContainers);
+        useProjectStore.setState({operation: "none"});
+    }
+
+    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/karavan/features/projects/ProjectStatusLabel.tsx
 
b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectStatusLabel.tsx
new file mode 100644
index 00000000..2a888102
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectStatusLabel.tsx
@@ -0,0 +1,80 @@
+/*
+ * 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 {ContainerType} from '@models/ProjectModels';
+import {BuildIcon, CogIcon, CubesIcon, DevIcon, InProgressIcon, LockIcon, 
PackageIcon} from '@patternfly/react-icons';
+import {Label} from "@patternfly/react-core";
+import {useStatusesStore} from "@stores/ProjectStore";
+import {shallow} from "zustand/shallow";
+import {useContainerStatusesStore} from "@stores/ContainerStatusesStore";
+
+interface Props {
+    projectId: string
+}
+
+export function ProjectStatusLabel(props: Props) {
+
+    const {projectId} = props;
+    const [deployments] = useStatusesStore((state) => [state.deployments], 
shallow)
+    const {containers} = useContainerStatusesStore();
+    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'} variant={variant} 
style={{padding: '8px'}} >
+                <div style={{display: 'flex', justifyContent: 'space-between', 
alignItems: 'center', gap: '6px', width: '100%'}}>
+                    {firstIcon}
+                    {typeIcon ? typeIcon : <CubesIcon color={typeIconColor}/>}
+                </div>
+            </Label>
+        )
+    } else {
+        return (
+            <div style={{display: 'flex', justifyContent: 'space-around', 
alignItems: 'center', gap: '0.2rem', padding: '8px'}}>
+                {/*<InProgressIcon/>*/}
+            </div>
+        )
+    }
+}
diff --git 
a/karavan-app/src/main/webui/src/karavan/features/projects/ProjectZipApi.tsx 
b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectZipApi.tsx
new file mode 100644
index 00000000..33a866f1
--- /dev/null
+++ b/karavan-app/src/main/webui/src/karavan/features/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/karavan/features/projects/ProjectsPage.tsx 
b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsPage.tsx
new file mode 100644
index 00000000..5d1733c4
--- /dev/null
+++ b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsPage.tsx
@@ -0,0 +1,88 @@
+import React, {useEffect, useState} from 'react';
+import {capitalize, Content, Nav, NavItem, NavList,} 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 "@features/project/developer/DeveloperManager";
+import {ErrorBoundaryWrapper} from "@shared/ui/ErrorBoundaryWrapper";
+import {ProjectsTab} from "@features/projects/ProjectsTab";
+import {ProjectFunctionHook} from "@app/navigation/ProjectFunctionHook";
+import {useDataPolling} from "@shared/polling/useDataPolling";
+import {useContainerStatusesStore} from "@stores/ContainerStatusesStore";
+
+export const IntegrationsMenus = ['integrations'] as const;
+export type IntegrationsMenu = typeof IntegrationsMenus[number];
+
+export function ProjectsPage() {
+
+    const [fetchProjects, projects, fetchProjectsCommited] = 
useProjectsStore((s) => [s.fetchProjects, s.projects, s.fetchProjectsCommited], 
shallow)
+    const [setProject] = useProjectStore((s) => [s.setProject], shallow);
+    const {fetchContainers} = useContainerStatusesStore();
+    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, 10000);
+
+    useEffect(() => {
+        fetchProjects();
+        fetchProjectsCommited();
+        refreshSharedData();
+    }, []);
+
+    function title() {
+        return (<Content component="h2">Projects</Content>)
+    }
+
+    const onNavSelect = (_: any, selectedItem: {
+                             groupId: number | string;
+                             itemId: number | string;
+                             to: string;
+                         }
+    ) => {
+        const menu = selectedItem.itemId;
+        setCurrentMenu(menu as IntegrationsMenu);
+        const isBuildIn = BUILD_IN_PROJECTS.includes(menu?.toString());
+        if (isBuildIn) {
+            const p = projects.find(p => p.projectId === menu);
+            if (p) {
+                setProject(p, "select");
+            }
+        }
+        setFile('none', undefined);
+    };
+
+    function getNavigation() {
+        return (
+            <Nav onSelect={onNavSelect} aria-label="Nav" variant="horizontal">
+                <NavList>
+                    {IntegrationsMenus.map((item, i) => {
+                        return (
+                            <NavItem key={item} preventDefault itemId={item} 
isActive={currentMenu === item} to={"#"}>
+                                {capitalize(item?.toString())}
+                            </NavItem>
+                        )
+                    })}
+                </NavList>
+            </Nav>
+        )
+    }
+
+    return (
+        <RightPanel
+            title={title()}
+            toolsStart={getNavigation()}
+            tools={undefined}
+            mainPanel={
+                <div className="right-panel-card">
+                    <ErrorBoundaryWrapper onError={error => 
console.error(error)}>
+                        {!showFilePanel && currentMenu === 'integrations' && 
<ProjectsTab/>}
+                        {showFilePanel && <DeveloperManager/>}
+                    </ErrorBoundaryWrapper>
+                </div>
+            }
+        />
+    )
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTab.tsx 
b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTab.tsx
new file mode 100644
index 00000000..6c568650
--- /dev/null
+++ b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTab.tsx
@@ -0,0 +1,146 @@
+/*
+ * 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, 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 {KaravanApi} from "@api/KaravanApi";
+import {CreateProjectModal} from "@features/projects/CreateProjectModal";
+import {DeleteProjectModal} from "@features/projects/DeleteProjectModal";
+import {useSearchStore} from "@stores/SearchStore";
+import {ComplexityProject} from "@features/projects/ComplexityModels";
+import {ComplexityApi} from "@features/projects/ComplexityApi";
+import ProjectsTableRow from "@features/projects/ProjectsTableRow";
+import {ProjectsToolbar} from "@features/projects/ProjectsToolbar";
+import {ProjectType} from "@models/ProjectModels";
+import {useDataPolling} from "@shared/polling/useDataPolling";
+
+export function ProjectsTab() {
+
+    const [projects, projectsCommited] = useProjectsStore((s) => [s.projects, 
s.projectsCommited], shallow)
+    const [operation] = useProjectStore((s) => [s.operation], shallow)
+    const [search, searchResults] = useSearchStore((s) => [s.search, 
s.searchResults], shallow)
+    const [complexities, setComplexities] = useState<ComplexityProject[]>([]);
+    const [labels, setLabels] = useState<any>();
+    const [selectedLabels, setSelectedLabels] = useState<string[]>([]);
+
+    useEffect(() => refreshActivity(), []);
+    useDataPolling('ProjectsTab', refreshActivity, 10000);
+
+    function refreshActivity() {
+        KaravanApi.getProjectsLabels(data => {
+            setLabels(data);
+        });
+        ComplexityApi.getComplexityProjects(complexities => {
+            setComplexities(complexities);
+        })
+    }
+
+    const toggleLabel = (label: string) => {
+        setSelectedLabels((prevSelectedLabels) => {
+            if (prevSelectedLabels.includes(label)) {
+                // Remove the label if it already exists in the array
+                return prevSelectedLabels.filter((item) => item !== label);
+            } else {
+                // Add the label if it doesn't exist in the array
+                return [...prevSelectedLabels, label];
+            }
+        });
+    };
+
+    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/>
+                <OuterScrollContainer>
+                    <InnerScrollContainer>
+                        <Table aria-label="Projects" variant='compact' 
isStickyHeader>
+                            <Thead>
+                                <Tr>
+                                    <Th key='status' screenReaderText='pass' 
modifier='fitContent'/>
+                                    <Th key='projectId'>Name</Th>
+                                    <Th key='name'>Description</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='complexity' 
modifier={"fitContent"} textCenter>Complexity</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 projectCommited = 
projectsCommited.find(pc => pc.projectId === project.projectId);
+                                    return (
+                                        <ProjectsTableRow
+                                            key={project.projectId}
+                                            project={project}
+                                            projectCommited={projectCommited}
+                                            complexity={complexity}
+                                            
labels={Array.isArray(labels?.[project.projectId]) ? 
labels?.[project.projectId] : []}
+                                            selectedLabels={selectedLabels}
+                                            onLabelClick={toggleLabel}
+                                        />
+                                    )
+                                })}
+                                {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/karavan/features/projects/ProjectsTableRow.tsx 
b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRow.tsx
new file mode 100644
index 00000000..e8d55b86
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRow.tsx
@@ -0,0 +1,117 @@
+import React from 'react';
+import {Badge, Button, Flex, FlexItem, Tooltip} from '@patternfly/react-core';
+import '@features/projects/Complexity.css';
+import {Td, Tr} from "@patternfly/react-table";
+import DeleteIcon from 
"@patternfly/react-icons/dist/js/icons/times-circle-icon";
+import CopyIcon from "@patternfly/react-icons/dist/esm/icons/copy-icon";
+import DownloadIcon from 
"@patternfly/react-icons/dist/esm/icons/download-icon";
+import {shallow} from "zustand/shallow";
+import {useNavigate} from "react-router-dom";
+import {BUILD_IN_PROJECTS, Project, ProjectCommited} from 
"@models/ProjectModels";
+import {useProjectStore} from "@stores/ProjectStore";
+import FileSaver from "file-saver";
+import TimeAgo from 'javascript-time-ago'
+import en from 'javascript-time-ago/locale/en'
+import {ROUTES} from "@app/navigation/Routes";
+import {ProjectStatusLabel} from "@features/projects/ProjectStatusLabel";
+import {ComplexityProject} from "@features/projects/ComplexityModels";
+import {ProjectZipApi} from "@features/projects/ProjectZipApi";
+import {ProjectsTableRowComplexity} from 
"@features/projects/ProjectsTableRowComplexity";
+import {ProjectsTableRowTimeLine} from 
"@features/projects/ProjectsTableRowTimeLine";
+
+TimeAgo.addDefaultLocale(en)
+
+interface Props {
+    project: Project
+    projectCommited?: ProjectCommited
+    complexity: ComplexityProject
+    labels: string[]
+    selectedLabels: string[]
+    onLabelClick: (label: string) => void
+}
+
+function ProjectsTableRow(props: Props) {
+
+    const {project, complexity, labels, selectedLabels, onLabelClick, 
projectCommited} = props;
+    const [setProject] = useProjectStore((state) => [state.setProject], 
shallow);
+    const navigate = useNavigate();
+
+    const isBuildIn = BUILD_IN_PROJECTS.includes(project.projectId);
+
+    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'}}>
+                {!isBuildIn && <ProjectStatusLabel 
projectId={project.projectId}/>}
+            </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.map((label) => (
+                                <Badge key={label} 
isRead={!selectedLabels.includes(label)} style={{fontWeight: 'normal', cursor: 
'pointer'}}
+                                       onClick={event => onLabelClick(label)}>
+                                    {label}
+                                </Badge>
+                            ))}
+                        </div>
+                    }
+                </div>
+            </Td>
+            <Td modifier={"nowrap"} textCenter>
+                <ProjectsTableRowTimeLine project={project} 
projectCommited={projectCommited} />
+            </Td>
+            <Td noPadding textCenter>
+                {!isBuildIn && <ProjectsTableRowComplexity 
complexity={complexity}/>}
+            </Td>
+            <Td className="project-action-buttons" 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" 
variant={"link"} isDanger icon={<DeleteIcon/>} onClick={e => {
+                                    setProject(project, "delete");
+                                }}></Button>
+                            </Tooltip>
+                        </FlexItem>
+                    }
+                    {!isBuildIn &&
+                        <FlexItem>
+                            <Tooltip content={"Copy"} position={"bottom"}>
+                                <Button className="dev-action-button" 
variant={"link"} icon={<CopyIcon/>}
+                                        onClick={e => {
+                                            setProject(project, "copy");
+                                        }}></Button>
+                            </Tooltip>
+                        </FlexItem>
+                    }
+                    <FlexItem>
+                        <Tooltip content={"Export"} position={"bottom-end"}>
+                            <Button className="dev-action-button" 
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/karavan/features/projects/ProjectsTableRowActivity.tsx
 
b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowActivity.tsx
new file mode 100644
index 00000000..06230798
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowActivity.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import {Label, LabelGroup} from "@patternfly/react-core";
+
+interface Props {
+    activeUsers: string[]
+}
+
+export function ProjectsTableRowActivity (props: Props) {
+
+    const {activeUsers} = props;
+
+    return (
+        <LabelGroup className='active-users' numLabels={3}>
+            {activeUsers.length > 0 && activeUsers.slice(0, 5).map(user =>
+                <Label key={user} color='blue' >{user}</Label>
+            )}
+        </LabelGroup>
+    )
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowComplexity.tsx
 
b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowComplexity.tsx
new file mode 100644
index 00000000..a15c70e7
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowComplexity.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import {Label, Tooltip} from '@patternfly/react-core';
+import './Complexity.css';
+import {ComplexityProject, ComplexityType, getComplexityColor, 
getMaxComplexity} from "./ComplexityModels";
+import IconEasy from "@patternfly/react-icons/dist/esm/icons/ok-icon";
+import IconNormal from "@patternfly/react-icons/dist/esm/icons/ok-icon";
+import IconComplex from 
"@patternfly/react-icons/dist/esm/icons/warning-triangle-icon";
+import {BUILD_IN_PROJECTS} from "@models/ProjectModels";
+
+interface Props {
+    complexity: ComplexityProject
+}
+
+export function ProjectsTableRowComplexity (props: Props) {
+
+    const {complexity} = props;
+    const routesComplexity = complexity.complexityRoute;
+    const restComplexity = complexity.complexityRest;
+    const javaComplexity = complexity.complexityJava;
+    const fileComplexity = complexity.complexityFiles;
+
+    const complexities: ComplexityType[] = [];
+    complexities.push(routesComplexity);
+    complexities.push(restComplexity);
+    complexities.push(javaComplexity);
+    complexities.push(fileComplexity);
+    const maxComplexity = getMaxComplexity(complexities)
+    const color = getComplexityColor(maxComplexity);
+    const isBuildIn = BUILD_IN_PROJECTS.includes(complexity.projectId);
+
+    const label = isBuildIn
+        ? <Label key='build-in' variant={"outline"} color={'blue'}><IconNormal 
color={'var(--pf-t--global--color--brand--default)'}/></Label>
+        : (
+            <Tooltip content={maxComplexity}>
+                <>
+                    {maxComplexity === 'easy' && <Label key='success' 
color={color}><IconEasy/></Label>}
+                    {maxComplexity === 'normal' && <Label key='info' 
color={color}><IconNormal/></Label>}
+                    {maxComplexity === 'complex' && <Label key='warning' 
color={color}><IconComplex/></Label>}
+                </>
+            </Tooltip>
+        )
+
+    return (
+        <div style={{display: "flex", gap: "3px", justifyContent: 'center', 
marginLeft: '16px', marginRight: '16px'}} className='complexity'>
+            {label}
+        </div>
+    )
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowTimeLine.css
 
b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowTimeLine.css
new file mode 100644
index 00000000..dc50f119
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/karavan/features/projects/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/karavan/features/projects/ProjectsTableRowTimeLine.tsx
 
b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowTimeLine.tsx
new file mode 100644
index 00000000..d2fd4476
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsTableRowTimeLine.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import {Label, ProgressStep, ProgressStepper} from '@patternfly/react-core';
+import '@features/projects/Complexity.css';
+import {Project, ProjectCommited} from "@models/ProjectModels";
+import TimeAgo from 'javascript-time-ago'
+import en from 'javascript-time-ago/locale/en'
+import './ProjectsTableRowTimeLine.css'
+import CheckCircleIcon from 
"@patternfly/react-icons/dist/esm/icons/check-circle-icon";
+import {InProgress} from "@carbon/icons-react";
+
+TimeAgo.addDefaultLocale(en)
+
+interface Props {
+    project: Project
+    projectCommited?: ProjectCommited
+}
+
+export function ProjectsTableRowTimeLine(props: Props) {
+
+    const {project, projectCommited} = props;
+    const timeAgo = new TimeAgo('en-US')
+
+    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/karavan/features/projects/ProjectsToolbar.tsx 
b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsToolbar.tsx
new file mode 100644
index 00000000..6a784332
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/karavan/features/projects/ProjectsToolbar.tsx
@@ -0,0 +1,105 @@
+import React, {useEffect, useState} from 'react';
+import {Button, TextInputGroup, TextInputGroupMain, TextInputGroupUtilities, 
Tooltip, TooltipPosition,} from '@patternfly/react-core';
+import {SearchIcon} from '@patternfly/react-icons';
+import {useAppConfigStore, useProjectStore} from "@stores/ProjectStore";
+import {Project} from "@models/ProjectModels";
+import {shallow} from "zustand/shallow";
+import RefreshIcon from "@patternfly/react-icons/dist/esm/icons/sync-alt-icon";
+import {ProjectService} from "@services/ProjectService";
+import {useSearchStore} from "@stores/SearchStore";
+import {useDebounceValue} from "usehooks-ts";
+import {SearchApi} from "@api/SearchApi";
+import TimesIcon from "@patternfly/react-icons/dist/esm/icons/times-icon";
+import PullIcon from "@patternfly/react-icons/dist/esm/icons/code-branch-icon";
+import {UploadProjectModal} from "@features/projects/UploadProjectModal";
+import {ModalConfirmation} from "@shared/ui/ModalConfirmation";
+
+export function ProjectsToolbar() {
+
+    const [search, setSearch, setSearchResults] = useSearchStore((s) => 
[s.search, s.setSearch, s.setSearchResults], shallow)
+    const [setProject] = useProjectStore((s) => [s.setProject], shallow)
+    const [showUpload, setShowUpload] = useState<boolean>(false);
+    const [debouncedSearch] = useDebounceValue(search, 300);
+    const [pullIsOpen, setPullIsOpen] = useState(false);
+    const [config] = useAppConfigStore((s) => [s.config], shallow);
+    const isDev = config.environment === 'dev';
+
+    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>
+        )
+    }
+
+    return (
+        <div className="project-files-toolbar" style={{justifyContent: 
"flex-end"}}>
+            <Tooltip content='Pull new Integrations from git' 
position={TooltipPosition.left}>
+                <Button icon={<PullIcon/>}
+                        variant={"link"}
+                        isDanger
+                        onClick={e => setPullIsOpen(true)}
+                />
+            </Tooltip>
+            <Button icon={<RefreshIcon/>}
+                    variant={"link"}
+                    onClick={e => ProjectService.refreshProjects()}
+            />
+            {searchInput()}
+            {isDev &&
+                <Button className="dev-action-button" variant="secondary"
+                        onClick={e => setShowUpload(true)}>
+                    Import project
+                </Button>
+            }
+            {isDev &&
+                <Button className="dev-action-button" variant="primary"
+                        onClick={e => setProject(new Project(), 'create')}>
+                    Create 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'
+            />
+        </div>
+    )
+}
\ No newline at end of file
diff --git 
a/karavan-app/src/main/webui/src/karavan/features/projects/SettingsToolbar.tsx 
b/karavan-app/src/main/webui/src/karavan/features/projects/SettingsToolbar.tsx
new file mode 100644
index 00000000..560c958d
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/karavan/features/projects/SettingsToolbar.tsx
@@ -0,0 +1,59 @@
+import React, {useState} from 'react';
+import {Button, Flex, FlexItem, Modal, ModalBody, ModalFooter, ModalHeader,} 
from '@patternfly/react-core';
+import {useAppConfigStore, useFileStore, useProjectStore} from 
"@stores/ProjectStore";
+import {shallow} from "zustand/shallow";
+import {ProjectType} from "@models/ProjectModels";
+import {KaravanApi} from "@api/KaravanApi";
+import {CatalogIcon} from '@patternfly/react-icons';
+import {EditorToolbar} from "@features/project/developer/EditorToolbar";
+
+export function SettingsToolbar() {
+
+    const [project] = useProjectStore((state) => [state.project], shallow)
+    const [file, operation] = useFileStore((state) => [state.file, 
state.operation], shallow)
+    const {config} = useAppConfigStore();
+    const [showConfirmation, setShowConfirmation] = useState<boolean>(false);
+
+    const isConfiguration = project.projectId === 
ProjectType.configuration.toString();
+    const isKamelets = project.projectId === ProjectType.kamelets.toString();
+    const isKubernetes = config.infrastructure === 'kubernetes';
+    const tooltip = isKubernetes ? "Save All Configmaps" : "Save all on shared 
volume";
+    const confirmMessage = isKubernetes ? "Save all configurations as 
Configmaps" : "Save all configurations on shared volume";
+
+    function shareConfigurations () {
+        KaravanApi.shareConfigurations(res => {});
+        setShowConfirmation(false);
+    }
+
+    function getConfirmation() {
+        return (<Modal
+            className="modal-confirm"
+            variant={"small"}
+            isOpen={showConfirmation}
+            onClose={() => setShowConfirmation(false)}
+            onEscapePress={e => setShowConfirmation(false)}>
+            <ModalHeader title="Confirmation" />
+            <ModalBody>
+                <div>{confirmMessage}</div>
+            </ModalBody>
+            <ModalFooter>
+                <Button key="confirm" variant="primary" 
onClick={shareConfigurations}>Confirm</Button>,
+                <Button key="cancel" variant="link" onClick={_ => 
setShowConfirmation(false)}>Cancel</Button>
+            </ModalFooter>
+        </Modal>)
+    }
+
+    function getToolbar() {
+        if (file !== undefined && isConfiguration) {
+            return (<EditorToolbar/>)
+        } else {
+            return (
+                <Flex className="toolbar" direction={{default: "row"}} 
alignItems={{default: "alignItemsCenter"}}>
+                    {showConfirmation && getConfirmation()}
+                </Flex>
+            )
+        }
+    }
+
+    return getToolbar();
+}
diff --git 
a/karavan-app/src/main/webui/src/karavan/features/projects/UploadProjectModal.tsx
 
b/karavan-app/src/main/webui/src/karavan/features/projects/UploadProjectModal.tsx
new file mode 100644
index 00000000..da58a409
--- /dev/null
+++ 
b/karavan-app/src/main/webui/src/karavan/features/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 "@features/project/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

Reply via email to