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();
