diff --git a/package-lock.json b/package-lock.json index cd08c0d4b..8ab9d9058 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2622,9 +2622,9 @@ "link": true }, "node_modules/@frinxio/gamma": { - "version": "6.0.3", - "resolved": "https://npm.pkg.github.com/download/@FRINXio/gamma/6.0.3/1740b5cc0999092d4d3ed04ae7f496bb07ad5d78", - "integrity": "sha512-OC9JmPh7GvvIM/fHWRt2V8gvZreogH29SOGJldHTiJNZ81hCB43tnjNoSzx0whY2H0gGVA/T+idetXoh+r24sQ==", + "version": "7.0.0", + "resolved": "https://npm.pkg.github.com/download/@FRINXio/gamma/7.0.0/6de610a77d250d94ac164c220ffcb35069e98b46", + "integrity": "sha512-sbEbrAXdaPfqOM2SxL5EqOaiRIz8lrYdbacqlIPAZmK4KfsYjXB/DY4ez10s8fvQKCJQVgPXR02WGKkRYgfnXw==", "dependencies": { "@chakra-ui/icons": "1.1.7", "@types/lodash": "4.14.181", @@ -18725,7 +18725,7 @@ }, "packages/frinx-api": { "name": "@frinx/api", - "version": "0.0.22", + "version": "0.0.23", "license": "MIT", "dependencies": { "fp-ts": "2.16.1", @@ -18774,7 +18774,7 @@ }, "packages/frinx-dashboard": { "name": "@frinx/dashboard", - "version": "6.1.0", + "version": "7.0.0", "dependencies": { "@chakra-ui/react": "2.8.1", "@chakra-ui/system": "2.6.1", @@ -18782,14 +18782,14 @@ "@emotion/styled": "11.11.0", "@fontsource/roboto": "^5.0.8", "@formspree/react": "^2.4.0", - "@frinx/api": "^0.0.22", + "@frinx/api": "^0.0.23", "@frinx/device-topology": "^2.0.0", "@frinx/inventory-client": "^2.0.0", "@frinx/resource-manager": "^2.0.0", "@frinx/shared": "^2.0.0", "@frinx/workflow-builder": "^2.0.0", "@frinx/workflow-ui": "^2.0.0", - "@frinxio/gamma": "6.0.3", + "@frinxio/gamma": "7.0.0", "@types/react": "17.0.65", "@types/react-dom": "18.2.7", "eventemitter3": "5.0.1", @@ -18887,7 +18887,7 @@ }, "packages/frinx-frontend-server": { "name": "@frinx/frontend-server", - "version": "6.1.0", + "version": "7.0.0", "license": "MIT", "dependencies": { "@types/express": "4.17.18", @@ -20890,7 +20890,7 @@ "@emotion/styled": "11.11.0", "@fontsource/roboto": "^5.0.8", "@formspree/react": "^2.4.0", - "@frinx/api": "^0.0.22", + "@frinx/api": "^0.0.23", "@frinx/device-topology": "^2.0.0", "@frinx/eslint-config-typescript": "20.0.1", "@frinx/inventory-client": "^2.0.0", @@ -20898,7 +20898,7 @@ "@frinx/shared": "^2.0.0", "@frinx/workflow-builder": "^2.0.0", "@frinx/workflow-ui": "^2.0.0", - "@frinxio/gamma": "6.0.3", + "@frinxio/gamma": "7.0.0", "@types/react": "17.0.65", "@types/react-dom": "18.2.7", "@typescript-eslint/eslint-plugin": "6.7.2", @@ -21273,9 +21273,9 @@ } }, "@frinxio/gamma": { - "version": "6.0.3", - "resolved": "https://npm.pkg.github.com/download/@FRINXio/gamma/6.0.3/1740b5cc0999092d4d3ed04ae7f496bb07ad5d78", - "integrity": "sha512-OC9JmPh7GvvIM/fHWRt2V8gvZreogH29SOGJldHTiJNZ81hCB43tnjNoSzx0whY2H0gGVA/T+idetXoh+r24sQ==", + "version": "7.0.0", + "resolved": "https://npm.pkg.github.com/download/@FRINXio/gamma/7.0.0/6de610a77d250d94ac164c220ffcb35069e98b46", + "integrity": "sha512-sbEbrAXdaPfqOM2SxL5EqOaiRIz8lrYdbacqlIPAZmK4KfsYjXB/DY4ez10s8fvQKCJQVgPXR02WGKkRYgfnXw==", "requires": { "@chakra-ui/icons": "1.1.7", "@types/lodash": "4.14.181", diff --git a/packages/frinx-api/package.json b/packages/frinx-api/package.json index d8303f67c..11bec7fc5 100644 --- a/packages/frinx-api/package.json +++ b/packages/frinx-api/package.json @@ -1,6 +1,6 @@ { "name": "@frinx/api", - "version": "0.0.22", + "version": "0.0.23", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", "typings": "dist/index.d.ts", diff --git a/packages/frinx-dashboard/bin/common.mjs b/packages/frinx-dashboard/bin/common.mjs index 45423c70b..74442d062 100644 --- a/packages/frinx-dashboard/bin/common.mjs +++ b/packages/frinx-dashboard/bin/common.mjs @@ -13,11 +13,6 @@ export async function prepareFiles() { await mkdir(fullPath(BUILD_CLIENT_PATH, 'static'), { recursive: true }); await copyFile(fullPath('../../public/index.html'), fullPath(BUILD_CLIENT_PATH, 'index.html')); await copyFile(fullPath('../../public/favicon.ico'), fullPath(BUILD_CLIENT_PATH, 'static/favicon.ico')); - await copyFile(fullPath('../../public/l3vpn-options.js'), fullPath(BUILD_CLIENT_PATH, 'static/l3vpn-options.js')); - await copyFile( - fullPath('../../node_modules/@frinxio/gamma/dist/l3vpn-options.js'), - fullPath(BUILD_CLIENT_PATH, 'static/l3vpn-options.js'), - ); } export function makeConfig(isProd) { diff --git a/packages/frinx-dashboard/package.json b/packages/frinx-dashboard/package.json index ad1e87cdb..ece226128 100644 --- a/packages/frinx-dashboard/package.json +++ b/packages/frinx-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@frinx/dashboard", - "version": "6.1.0", + "version": "7.0.0", "private": false, "dependencies": { "@chakra-ui/react": "2.8.1", @@ -9,14 +9,14 @@ "@emotion/styled": "11.11.0", "@fontsource/roboto": "^5.0.8", "@formspree/react": "^2.4.0", - "@frinx/api": "^0.0.22", + "@frinx/api": "^0.0.23", "@frinx/device-topology": "^2.0.0", "@frinx/inventory-client": "^2.0.0", "@frinx/resource-manager": "^2.0.0", "@frinx/shared": "^2.0.0", "@frinx/workflow-builder": "^2.0.0", "@frinx/workflow-ui": "^2.0.0", - "@frinxio/gamma": "6.0.3", + "@frinxio/gamma": "7.0.0", "@types/react": "17.0.65", "@types/react-dom": "18.2.7", "eventemitter3": "5.0.1", diff --git a/packages/frinx-dashboard/src/app.tsx b/packages/frinx-dashboard/src/app.tsx index 5a4a61020..391e56a21 100644 --- a/packages/frinx-dashboard/src/app.tsx +++ b/packages/frinx-dashboard/src/app.tsx @@ -5,7 +5,6 @@ import Dashboard from './components/dashboard/dashboard'; import FeedbackWidget from './components/feedback-widget/feedback-widget'; import Header from './components/header/header'; import DeviceTopologyApp from './device-topology-app'; -import GammaApp from './gamma-app'; import InventoryApp from './inventory-app'; import ResourceManagerApp from './resource-manager-app'; import UniflowApp from './uniflow-app'; @@ -22,7 +21,6 @@ const App: FC = ({ basename, isAuthEnabled }) => { } /> - } /> } /> } /> } /> diff --git a/packages/frinx-dashboard/src/components/app-menu/app-menu.tsx b/packages/frinx-dashboard/src/components/app-menu/app-menu.tsx index 68f338d60..ee218a6d2 100644 --- a/packages/frinx-dashboard/src/components/app-menu/app-menu.tsx +++ b/packages/frinx-dashboard/src/components/app-menu/app-menu.tsx @@ -55,7 +55,7 @@ const AppMenu: VoidFunctionComponent = () => { Blueprints Transactions Locations - UniConfig Shell + {/* UniConfig Shell */} } /> diff --git a/packages/frinx-dashboard/src/components/header/header.tsx b/packages/frinx-dashboard/src/components/header/header.tsx index 244444ebf..19f488389 100644 --- a/packages/frinx-dashboard/src/components/header/header.tsx +++ b/packages/frinx-dashboard/src/components/header/header.tsx @@ -66,10 +66,6 @@ const Header: VoidFunctionComponent = ({ isAuthEnabled }) => { Resource manager - - - L3VPN Automation - Device Topology diff --git a/packages/frinx-device-topology/src/helpers/map-marker-helper.ts b/packages/frinx-device-topology/src/helpers/map-marker-helper.ts index 98c9c337c..03ef13bd2 100644 --- a/packages/frinx-device-topology/src/helpers/map-marker-helper.ts +++ b/packages/frinx-device-topology/src/helpers/map-marker-helper.ts @@ -3,6 +3,15 @@ import L from 'leaflet'; export const DEFAULT_ICON = new L.Icon({ iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png', shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', - iconAnchor: [13, 41], + iconAnchor: [12, 41], popupAnchor: [0, -30], }); + +export const RED_DEFAULT_ICON = new L.Icon({ + iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-red.png', + shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png', + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41], +}); diff --git a/packages/frinx-device-topology/src/helpers/topology-helpers.ts b/packages/frinx-device-topology/src/helpers/topology-helpers.ts index f20154702..14e044b8a 100644 --- a/packages/frinx-device-topology/src/helpers/topology-helpers.ts +++ b/packages/frinx-device-topology/src/helpers/topology-helpers.ts @@ -9,6 +9,7 @@ import { SynceGraphNode, PtpGraphNode, MplsGraphNode, + Position, } from '../pages/topology/graph.helpers'; import { NetNode, PtpDeviceDetails, SynceDeviceDetails } from '../__generated__/graphql'; @@ -490,3 +491,26 @@ export function getGmPathHopsCount(gmPathIds: string[], devicePrefix: 'PtpDevice export function getPtpProfile(ptpNodes: PtpGraphNode[]): string | null { return ptpNodes.at(0)?.details.ptpProfile ?? null; } + +// returns pointer X constrained to SVG vieport +// if SVG viewport is null, constrained only to 0 +export function getConstrainedPointerX(newX: number, viewPort: SVGElement | null): number { + const viewPortWidth = viewPort?.clientWidth; + const rightConstrainedX = viewPortWidth && newX > viewPortWidth ? viewPortWidth : newX; + return newX < 0 ? 0 : rightConstrainedX; +} + +// returns pointer Y constrained to SVG vieport +// if SVG viewport is null, constrained only to 0 +export function getConstrainedPointerY(newY: number, viewPort: SVGElement | null): number { + const viewPortHeight = viewPort?.clientHeight; + const rightConstrainedY = viewPortHeight && newY > viewPortHeight ? viewPortHeight : newY; + return newY < 0 ? 0 : rightConstrainedY; +} +// returns Position constrained to SVG vieport +// if SVG viewport is null, constrained only to 0 +export function getConstrainedPosition(newPosition: Position, viewPort: SVGElement | null): Position { + const x = getConstrainedPointerX(newPosition.x, viewPort); + const y = getConstrainedPointerY(newPosition.y, viewPort); + return { x, y }; +} diff --git a/packages/frinx-device-topology/src/pages/topology/lldp/nodes.tsx b/packages/frinx-device-topology/src/pages/topology/lldp/nodes.tsx index 06889f11b..9df04d086 100644 --- a/packages/frinx-device-topology/src/pages/topology/lldp/nodes.tsx +++ b/packages/frinx-device-topology/src/pages/topology/lldp/nodes.tsx @@ -2,7 +2,7 @@ import { unwrap, usePerformanceMonitoring } from '@frinx/shared'; import React, { useCallback, useEffect, useState, VoidFunctionComponent } from 'react'; import { gql, useSubscription } from 'urql'; import NodeIcon from '../../../components/node-icons/node-icon'; -import { GraphNodeWithDiff } from '../../../helpers/topology-helpers'; +import { getConstrainedPosition, GraphNodeWithDiff } from '../../../helpers/topology-helpers'; import { setSelectedNode, setSelectedNodeLoad, @@ -112,9 +112,16 @@ const Nodes: VoidFunctionComponent = ({ nodesWithDiff, onNodePositionUpda const x = event.clientX - bbox.left; const y = event.clientY - bbox.top; const nodeId = unwrap(position.nodeId); - const newX = nodePositions[nodeId].x - (position.offset.x - x); - const newY = nodePositions[nodeId].y - (position.offset.y - y); - onNodePositionUpdate(nodeId, { x: newX, y: newY }); + + const newPosition = getConstrainedPosition( + { + x: nodePositions[nodeId].x - (position.offset.x - x), + y: nodePositions[nodeId].y - (position.offset.y - y), + }, + event.currentTarget.viewportElement, + ); + + onNodePositionUpdate(nodeId, newPosition); } }, [ diff --git a/packages/frinx-device-topology/src/pages/topology/map/map-topology.container.tsx b/packages/frinx-device-topology/src/pages/topology/map/map-topology.container.tsx index 174c6978f..0d7339928 100644 --- a/packages/frinx-device-topology/src/pages/topology/map/map-topology.container.tsx +++ b/packages/frinx-device-topology/src/pages/topology/map/map-topology.container.tsx @@ -2,10 +2,10 @@ import React, { useEffect, useRef, VoidFunctionComponent, useState } from 'react import { MapContainer, Marker, Popup, TileLayer, useMap, Polyline } from 'react-leaflet'; import { useClient } from 'urql'; import MarkerClusterGroup from 'react-leaflet-cluster'; -import { Box, Button, Heading } from '@chakra-ui/react'; +import { Box, Button, Card, CardBody, CloseButton, Heading } from '@chakra-ui/react'; import L, { LatLngBoundsLiteral, LatLngTuple } from 'leaflet'; import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM_LEVEL } from '../../../helpers/topology-helpers'; -import { DEFAULT_ICON } from '../../../helpers/map-marker-helper'; +import { RED_DEFAULT_ICON } from '../../../helpers/map-marker-helper'; import { useStateContext } from '../../../state.provider'; import { getDeviceMetadata, @@ -206,7 +206,7 @@ const MapTopologyContainerDescendant: VoidFunctionComponent = () => { { })} )} + {selectedDeviceData && selectedDeviceData.geolocation?.latitude && selectedDeviceData.geolocation?.longitude && ( + + )} + {selectedDeviceData && ( + + + + + + {selectedDeviceData.deviceName ?? '-'} + + + + + Location name + + {selectedDeviceData.locationName ?? '-'} + + + + Latitude + + {selectedDeviceData.geolocation?.latitude} + + + + Longitude + + {selectedDeviceData.geolocation?.longitude} + + + + )} ); }; @@ -280,7 +317,7 @@ const MapTopologyContainerDescendant: VoidFunctionComponent = () => { const MapTopologyContainer: VoidFunctionComponent = () => { return ( = ({ nodes, onNodePositionUpdate, const x = event.clientX - bbox.left; const y = event.clientY - bbox.top; const nodeId = unwrap(position.nodeId); - const newX = mplsNodePositions[nodeId].x - (position.offset.x - x); - const newY = mplsNodePositions[nodeId].y - (position.offset.y - y); - onNodePositionUpdate(nodeId, { x: newX, y: newY }); + + const newPosition = getConstrainedPosition( + { + x: mplsNodePositions[nodeId].x - (position.offset.x - x), + y: mplsNodePositions[nodeId].y - (position.offset.y - y), + }, + event.currentTarget.viewportElement, + ); + + onNodePositionUpdate(nodeId, newPosition); } }; const handlePointerUp = (node: MplsGraphNode) => { diff --git a/packages/frinx-device-topology/src/pages/topology/mpls/mpls-table.tsx b/packages/frinx-device-topology/src/pages/topology/mpls/mpls-table.tsx index 58a04e1f6..26a9993e7 100644 --- a/packages/frinx-device-topology/src/pages/topology/mpls/mpls-table.tsx +++ b/packages/frinx-device-topology/src/pages/topology/mpls/mpls-table.tsx @@ -12,7 +12,7 @@ const MplsTable: VoidFunctionComponent = ({ data }) => { ['inputLabel', 'Input Label'], ['inputInterface', 'Input Interface'], ['outputLabel', 'Output Label'], - ['outputInterface', 'Output Ingerface'], + ['outputInterface', 'Output Interface'], ['operState', 'Oper State'], ['mplsOperation', 'MPLS Operation'], ['ldpPrefix', 'Ldp Prefix'], @@ -32,7 +32,7 @@ const MplsTable: VoidFunctionComponent = ({ data }) => { {data.map((row) => { return ( - + {[...tableColumns.keys()].map((column) => ( {row[column] ?? '-'} ))} diff --git a/packages/frinx-device-topology/src/pages/topology/ptp/ptp-nodes.tsx b/packages/frinx-device-topology/src/pages/topology/ptp/ptp-nodes.tsx index 6bc9bfe32..3a6772f3c 100644 --- a/packages/frinx-device-topology/src/pages/topology/ptp/ptp-nodes.tsx +++ b/packages/frinx-device-topology/src/pages/topology/ptp/ptp-nodes.tsx @@ -1,7 +1,7 @@ import unwrap from '@frinx/shared/src/helpers/unwrap'; import React, { useState, VoidFunctionComponent } from 'react'; import PtpNodeIcon from '../../../components/node-icons/ptp-node-icon'; -import { PtpGraphNodeWithDiff } from '../../../helpers/topology-helpers'; +import { getConstrainedPosition, PtpGraphNodeWithDiff } from '../../../helpers/topology-helpers'; import { setSelectedPtpNode, setUnconfimedNodeIdForGmPathSearch } from '../../../state.actions'; import { useStateContext } from '../../../state.provider'; import { Position, PtpGraphNode } from '../graph.helpers'; @@ -75,9 +75,16 @@ const PtpNodes: VoidFunctionComponent = ({ const x = event.clientX - bbox.left; const y = event.clientY - bbox.top; const nodeId = unwrap(position.nodeId); - const newX = ptpNodePositions[nodeId].x - (position.offset.x - x); - const newY = ptpNodePositions[nodeId].y - (position.offset.y - y); - onNodePositionUpdate(nodeId, { x: newX, y: newY }); + + const newPosition = getConstrainedPosition( + { + x: ptpNodePositions[nodeId].x - (position.offset.x - x), + y: ptpNodePositions[nodeId].y - (position.offset.y - y), + }, + event.currentTarget.viewportElement, + ); + + onNodePositionUpdate(nodeId, newPosition); } }; const handlePointerUp = (node: PtpGraphNode) => { diff --git a/packages/frinx-device-topology/src/pages/topology/synce/synce-nodes.tsx b/packages/frinx-device-topology/src/pages/topology/synce/synce-nodes.tsx index 8a9d98626..1d66d3dfa 100644 --- a/packages/frinx-device-topology/src/pages/topology/synce/synce-nodes.tsx +++ b/packages/frinx-device-topology/src/pages/topology/synce/synce-nodes.tsx @@ -1,7 +1,7 @@ import { unwrap } from '@frinx/shared'; import React, { useState, VoidFunctionComponent } from 'react'; import SynceNodeIcon from '../../../components/node-icons/synce-node-icon'; -import { SynceGraphNodeWithDiff } from '../../../helpers/topology-helpers'; +import { getConstrainedPosition, SynceGraphNodeWithDiff } from '../../../helpers/topology-helpers'; import { setSelectedSynceNode, setUnconfimedNodeIdForGmPathSearch } from '../../../state.actions'; import { useStateContext } from '../../../state.provider'; import { Position, SynceGraphNode } from '../graph.helpers'; @@ -68,9 +68,16 @@ const SynceNodes: VoidFunctionComponent = ({ nodes, onNodePositionUpdate, const x = event.clientX - bbox.left; const y = event.clientY - bbox.top; const nodeId = unwrap(position.nodeId); - const newX = synceNodePositions[nodeId].x - (position.offset.x - x); - const newY = synceNodePositions[nodeId].y - (position.offset.y - y); - onNodePositionUpdate(nodeId, { x: newX, y: newY }); + + const newPosition = getConstrainedPosition( + { + x: synceNodePositions[nodeId].x - (position.offset.x - x), + y: synceNodePositions[nodeId].y - (position.offset.y - y), + }, + event.currentTarget.viewportElement, + ); + + onNodePositionUpdate(nodeId, newPosition); } }; const handlePointerUp = (node: SynceGraphNode) => { diff --git a/packages/frinx-device-topology/src/state.reducer.ts b/packages/frinx-device-topology/src/state.reducer.ts index f9830d87f..8ecc8180a 100644 --- a/packages/frinx-device-topology/src/state.reducer.ts +++ b/packages/frinx-device-topology/src/state.reducer.ts @@ -555,7 +555,7 @@ export function stateReducer(state: State, action: StateAction): State { } case 'SET_DEVICES_METADATA': { - acc.devicesMetadata = action.payload; + acc.devicesMetadata = action.payload.filter((d) => d.geolocation.latitude && d.geolocation.longitude); return acc; } case 'SET_SELECTED_PTP_NODE': { @@ -589,6 +589,7 @@ export function stateReducer(state: State, action: StateAction): State { case 'SET_SELECTED_MPLS_NODE': { if (acc.selectedNode?.id !== action.node?.id) { acc.selectedEdge = null; + acc.lspCounts = []; } acc.selectedNode = action.node; const connectedEdges = acc.synceEdges.filter( diff --git a/packages/frinx-frontend-server/.env.example b/packages/frinx-frontend-server/.env.example index 219ed2459..27f0c40af 100644 --- a/packages/frinx-frontend-server/.env.example +++ b/packages/frinx-frontend-server/.env.example @@ -15,6 +15,5 @@ SCHELLAR_API_DOCS_URL="http://127.0.0.1/api/schellar/docs" PERFORMANCE_MONITOR_API_DOCS_URL="http://127.0.0.1/api/performance-monitor/docs" TOPOLOGY_DISCOVERY_API_DOCS_URL="http://127.0.0.1/api/topology-disovery/docs" MSAL_AUTHORITY="https://login.microsoftonline.com/common/" -L3VPN_ENABLED=true DEVICE_TOPOLOGY_ENABLED=false PERFORMANCE_MONITORING_ENABLED=true diff --git a/packages/frinx-frontend-server/package.json b/packages/frinx-frontend-server/package.json index 0db66d634..bffe1bd7d 100644 --- a/packages/frinx-frontend-server/package.json +++ b/packages/frinx-frontend-server/package.json @@ -1,6 +1,6 @@ { "name": "@frinx/frontend-server", - "version": "6.1.0", + "version": "7.0.0", "main": "dist/server.js", "files": [ "dist/*" diff --git a/packages/frinx-inventory-client/src/__generated__/graphql.ts b/packages/frinx-inventory-client/src/__generated__/graphql.ts index 5bdab31f8..4ca9f84ce 100644 --- a/packages/frinx-inventory-client/src/__generated__/graphql.ts +++ b/packages/frinx-inventory-client/src/__generated__/graphql.ts @@ -742,6 +742,11 @@ export type DeviceMetadata = { nodes: Maybe>>; }; +export type DeviceNeighbors = { + __typename?: 'DeviceNeighbors'; + neighbors: Maybe>>; +}; + export type DeviceOrderByInput = { direction: SortDirection; sortKey: SortDeviceBy; @@ -921,6 +926,12 @@ export type FilterDevicesInput = { labels?: InputMaybe>; }; +export type FilterDevicesMetadatasInput = { + deviceName?: InputMaybe; + polygon?: InputMaybe; + topologyType?: InputMaybe; +}; + export type FilterEventHandlerInput = { evaluatorType?: InputMaybe; event?: InputMaybe; @@ -932,6 +943,15 @@ export type FilterLabelsInput = { name: Scalars['String']['input']; }; +export type FilterLocationsInput = { + name?: InputMaybe; +}; + +export type FilterNeighborInput = { + deviceName: Scalars['String']['input']; + topologyType: TopologyType; +}; + export type FilterStreamsInput = { deviceName?: InputMaybe; labels?: InputMaybe>; @@ -973,8 +993,8 @@ export type GraphEdge = { }; export type GraphEdgeStatus = - | 'ok' - | 'unknown'; + | 'OK' + | 'UNKNOWN'; export type GraphNode = BaseGraphNode & { __typename?: 'GraphNode'; @@ -1103,6 +1123,25 @@ export type LocationEdge = { node: Location; }; +export type LocationOrderByInput = { + direction: SortDirection; + sortKey: SortLocationBy; +}; + +export type LspPath = { + __typename?: 'LspPath'; + metadata: Maybe; + path: Array; +}; + +export type LspPathMetadata = { + __typename?: 'LspPathMetadata'; + fromDevice: Maybe; + signalization: Maybe; + toDevice: Maybe; + uptime: Maybe; +}; + export type LspTunnel = { __typename?: 'LspTunnel'; fromDevice: Maybe; @@ -1149,12 +1188,30 @@ export type MplsGraphNodeInterface = { status: GraphEdgeStatus; }; +export type MplsLspCount = { + __typename?: 'MplsLspCount'; + counts: Maybe>>; +}; + +export type MplsLspCountItem = { + __typename?: 'MplsLspCountItem'; + incomingLsps: Maybe; + outcomingLsps: Maybe; + target: Maybe; +}; + export type MplsTopology = { __typename?: 'MplsTopology'; edges: Array; nodes: Array; }; +export type MplsTopologyVersionData = { + __typename?: 'MplsTopologyVersionData'; + edges: Array; + nodes: Array; +}; + export type Mutation = { __typename?: 'Mutation'; conductor: ConductorMutation; @@ -1163,6 +1220,12 @@ export type Mutation = { scheduler: SchedulerMutation; }; +export type Neighbor = { + __typename?: 'Neighbor'; + deviceId: Scalars['String']['output']; + deviceName: Scalars['String']['output']; +}; + export type NetInterface = { __typename?: 'NetInterface'; id: Scalars['String']['output']; @@ -1184,6 +1247,7 @@ export type NetNode = { name: Scalars['String']['output']; networks: Array; nodeId: Scalars['String']['output']; + phyDeviceName: Maybe; }; export type NetRoutingPathNode = { @@ -1251,6 +1315,10 @@ export type PollData = { workerId: Maybe; }; +export type PolygonInput = { + polygon?: InputMaybe>>>; +}; + /** Entity representing capacity of a pool */ export type PoolCapacityPayload = { __typename?: 'PoolCapacityPayload'; @@ -1566,6 +1634,9 @@ export type SortExecutedWorkflowsDirection = | 'asc' | 'desc'; +export type SortLocationBy = + | 'name'; + export type SortResourcePoolsInput = { direction: OrderDirection; field?: InputMaybe; @@ -1993,10 +2064,17 @@ export type TopologyCommonNodes = { }; export type TopologyLayer = - | 'EthTopology' - | 'MplsTopology' - | 'PhysicalTopology' - | 'PtpTopology'; + | 'ETH_TOPOLOGY' + | 'MPLS_TOPOLOGY' + | 'PHYSICAL_TOPOLOGY' + | 'PTP_TOPOLOGY'; + +export type TopologyType = + | 'ETH_TOPOLOGY' + | 'MPLS_TOPOLOGY' + | 'NETWORK_TOPOLOGY' + | 'PHYSICAL_TOPOLOGY' + | 'PTP_TOPOLOGY'; export type Transaction = { __typename?: 'Transaction'; @@ -3411,11 +3489,15 @@ export type DeviceInventoryQuery = { countries: CountryConnection; dataStore: Maybe; deviceMetadata: Maybe; + deviceNeighbor: Maybe; devices: DeviceConnection; kafkaHealthCheck: Maybe; labels: LabelConnection; locations: LocationConnection; + lspPath: Maybe; + mplsLspCount: Maybe; mplsTopology: Maybe; + mplsTopologyVersionData: MplsTopologyVersionData; netTopology: Maybe; netTopologyVersionData: NetTopologyVersionData; node: Maybe; @@ -3466,6 +3548,16 @@ export type DeviceInventoryQueryDataStoreArgs = { }; +export type DeviceInventoryQueryDeviceMetadataArgs = { + filter?: InputMaybe; +}; + + +export type DeviceInventoryQueryDeviceNeighborArgs = { + filter?: InputMaybe; +}; + + export type DeviceInventoryQueryDevicesArgs = { after?: InputMaybe; before?: InputMaybe; @@ -3488,8 +3580,26 @@ export type DeviceInventoryQueryLabelsArgs = { export type DeviceInventoryQueryLocationsArgs = { after?: InputMaybe; before?: InputMaybe; + filter?: InputMaybe; first?: InputMaybe; last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type DeviceInventoryQueryLspPathArgs = { + deviceId: Scalars['String']['input']; + lspId: Scalars['String']['input']; +}; + + +export type DeviceInventoryQueryMplsLspCountArgs = { + deviceId: Scalars['String']['input']; +}; + + +export type DeviceInventoryQueryMplsTopologyVersionDataArgs = { + version: Scalars['String']['input']; }; @@ -4360,10 +4470,12 @@ export type UpdateStreamMutationVariables = Exact<{ export type UpdateStreamMutation = { __typename?: 'Mutation', deviceInventory: { __typename?: 'deviceInventoryMutation', updateStream: { __typename?: 'UpdateStreamPayload', stream: { __typename?: 'Stream', id: string, streamName: string, deviceName: string, isActive: boolean } | null } } }; export type LocationListQueryVariables = Exact<{ + filter?: InputMaybe; first?: InputMaybe; - last?: InputMaybe; after?: InputMaybe; + last?: InputMaybe; before?: InputMaybe; + orderBy?: InputMaybe; }>; diff --git a/packages/frinx-inventory-client/src/components/edit-device-location-modal.tsx b/packages/frinx-inventory-client/src/components/edit-device-location-modal.tsx index 80e097e19..fdde4add4 100644 --- a/packages/frinx-inventory-client/src/components/edit-device-location-modal.tsx +++ b/packages/frinx-inventory-client/src/components/edit-device-location-modal.tsx @@ -73,6 +73,9 @@ const EditDeviceLocationModal: FC = ({ isOpen, onClose, title, initialLoc }); useEffect(() => { + if (Number.isNaN(parseFloat(values.latitude)) || Number.isNaN(parseFloat(values.longitude))) { + return; + } setParsedMapPosition([parseFloat(values.latitude), parseFloat(values.longitude)]); }, [values]); diff --git a/packages/frinx-inventory-client/src/components/edit-location-map-modal.tsx b/packages/frinx-inventory-client/src/components/edit-location-map-modal.tsx new file mode 100644 index 000000000..6272b9659 --- /dev/null +++ b/packages/frinx-inventory-client/src/components/edit-location-map-modal.tsx @@ -0,0 +1,284 @@ +import { + Box, + Button, + Divider, + Flex, + FormControl, + FormErrorMessage, + FormLabel, + Heading, + HStack, + Input, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Stack, +} from '@chakra-ui/react'; +import { useFormik } from 'formik'; +import * as yup from 'yup'; +import React, { useEffect, useState, VoidFunctionComponent } from 'react'; +import { Marker as MarkerType } from 'leaflet'; +import { MapContainer, Marker, Popup, TileLayer, useMap, useMapEvents } from 'react-leaflet'; +import { Autocomplete } from '@frinx/shared/src'; +import { DEFAULT_ICON } from '../helpers/map'; +import { LocationData } from '../pages/create-device/create-device-page'; +import { UpdateDeviceInput } from '../__generated__/graphql'; + +export type LocationModal = { + deviceId: string; + location: { name: string; latitude: number | null; longitude: number | null } | null; +}; + +type LocationOption = { + value: string; + label: string; + key: string; +}; + +type Location = { + id: string; + name: string; + latitude: number | null; + longitude: number | null; +}; + +type Props = { + onClose: () => void; + locationModal: LocationModal; + locationOptions: LocationOption[]; + locationsList: Location[]; + onAddDeviceLocation: (locationData: LocationData) => void; + onUpdateDeviceLocation: (id: string, updatedDeviceData: UpdateDeviceInput) => void; +}; + +export type FormValues = { + id?: string; + name: string; + latitude: string; + longitude: string; +}; + +const INITIAL_VALUES = { name: '', latitude: '', longitude: '' }; + +const AddLocationSchema = yup.object().shape({ + name: yup.string().required('Location name is required'), + latitude: yup.number().typeError('Please enter a number').required('Please enter a number'), + longitude: yup.number().typeError('Please enter a number').required('Please enter a number'), +}); + +const LocationMapModal: VoidFunctionComponent = ({ + onClose, + locationModal, + locationOptions, + locationsList, + onAddDeviceLocation, + onUpdateDeviceLocation, +}) => { + const [selectedLocation, setSelectedLocation] = useState( + locationOptions.find((l) => l.label === locationModal.location?.name), + ); + const [createNewLocation, setCreateNewLocation] = useState(false); + const [markerRef, setMarkerRef] = useState(null); + + const { values, handleSubmit, handleChange, resetForm, setFieldValue, errors } = useFormik({ + enableReinitialize: true, + initialValues: INITIAL_VALUES, + validationSchema: AddLocationSchema, + onSubmit: (data) => { + const locationInput = { + name: data.name, + coordinates: { + latitude: parseFloat(data.latitude.toString()), + longitude: parseFloat(data.longitude.toString()), + }, + }; + const newLocationOption = { value: locationInput.name, label: locationInput.name, key: locationInput.name }; + + setSelectedLocation(newLocationOption); + setCreateNewLocation(false); + onAddDeviceLocation(locationInput); + resetForm(); + }, + }); + + useEffect(() => { + markerRef?.openPopup(); + }, [markerRef]); + + const handleLocationChange = (locationName?: string | null) => { + if (locationName) { + const location = locationOptions.find((loc) => loc.value === locationName); + setSelectedLocation(location); + } + }; + + const RecenterMap = ({ lat, lng }: { lat: number; lng: number }) => { + const map = useMap(); + useEffect(() => { + map.setView([lat, lng], map.getZoom()); + }, [lat, lng, map]); + + return null; + }; + + const displayLocation = locationsList.find((l) => l.name === selectedLocation?.value); + const newDeviceLocationId = locationsList.find((l) => selectedLocation?.key === l.name)?.id; + + const latitude = displayLocation?.latitude || parseFloat(values.latitude) || 0; + const longitude = displayLocation?.longitude || parseFloat(values.longitude) || 0; + + const ClickableMap = () => { + useMapEvents({ + click: (e) => { + if (createNewLocation) { + setFieldValue('latitude', e.latlng.lat.toString()); + setFieldValue('longitude', e.latlng.lng.toString()); + setSelectedLocation(undefined); // Clear selected location when manually setting coordinates + } + }, + }); + + return null; + }; + + return ( + + + + {displayLocation?.name || 'No location selected'} + + + Change device location + + ({ + ...option, + key: option.value, + }))} + onChange={(e) => { + handleLocationChange(e?.value); + setCreateNewLocation(false); + }} + selectedItem={selectedLocation} + /> + + + {createNewLocation && ( +
+ + + + Location name + + {errors.name && {errors.name}} + + + + Latitude + { + handleChange(e); + setSelectedLocation(undefined); // Clear selected location when entering manual coordinates + }} + value={values.latitude || ''} + placeholder="Enter number" + /> + {errors.latitude && {errors.latitude}} + + + Longitude + { + handleChange(e); + setSelectedLocation(undefined); // Clear selected location when entering manual coordinates + }} + value={values.longitude || ''} + placeholder="Enter number" + /> + {errors.longitude && {errors.longitude}} + + + + + + + + )} + + + + { + setMarkerRef(el); + }} + position={[latitude, longitude]} + icon={DEFAULT_ICON} + > + + + + {displayLocation?.name || values.name || 'No name provided'} + + + + + Latitude + + {latitude} + + + + Longitude + + {longitude} + + + + + {createNewLocation && } + + +
+ + + + + + +
+
+ ); +}; + +export default LocationMapModal; diff --git a/packages/frinx-inventory-client/src/components/view-location-map-modal.tsx b/packages/frinx-inventory-client/src/components/view-location-map-modal.tsx new file mode 100644 index 000000000..e145ad4bb --- /dev/null +++ b/packages/frinx-inventory-client/src/components/view-location-map-modal.tsx @@ -0,0 +1,110 @@ +import { + Box, + Button, + Heading, + HStack, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from '@chakra-ui/react'; +import React, { useEffect, useState, VoidFunctionComponent } from 'react'; +import { Marker as MarkerType } from 'leaflet'; +import { MapContainer, Marker, Popup, TileLayer } from 'react-leaflet'; +import { DEFAULT_ICON } from '../helpers/map'; + +export type ViewLocationModal = { + title?: string; + location: { name: string; latitude: number | null; longitude: number | null } | null; +}; + +type Props = { + onClose: () => void; + locationModal: ViewLocationModal; +}; + +const ViewLocationMapModal: VoidFunctionComponent = ({ onClose, locationModal }) => { + const [markerRef, setMarkerRef] = useState(null); + + useEffect(() => { + markerRef?.openPopup(); + }, [markerRef]); + + return ( + + + + {locationModal.title ?? locationModal.location?.name} + + + {locationModal.location?.latitude && locationModal.location?.longitude ? ( + + + { + setMarkerRef(el); + }} + position={[locationModal.location.latitude, locationModal.location.longitude]} + icon={DEFAULT_ICON} + > + + + + {locationModal.title ?? locationModal.location.name} + + + {locationModal.title && ( + + + Location name + + {locationModal.location.name ?? '-'} + + )} + + + Latitude + + {locationModal.location.latitude} + + + + Longitude + + {locationModal.location.longitude} + + + + + ) : ( + + + Location name + + {locationModal.location?.name ?? '-'} + + )} + + + + + + + + + ); +}; + +export default ViewLocationMapModal; diff --git a/packages/frinx-inventory-client/src/pages/device-list/device-list.tsx b/packages/frinx-inventory-client/src/pages/device-list/device-list.tsx index 0383f45da..e088d6f81 100644 --- a/packages/frinx-inventory-client/src/pages/device-list/device-list.tsx +++ b/packages/frinx-inventory-client/src/pages/device-list/device-list.tsx @@ -29,6 +29,7 @@ import { ConfirmDeleteModal, KafkaHealthCheckToolbar, usePerformanceMonitoring, + omitNullValue, } from '@frinx/shared'; import { Item } from 'chakra-ui-autocomplete'; import React, { FormEvent, useEffect, useMemo, useState, VoidFunctionComponent } from 'react'; @@ -37,6 +38,7 @@ import { gql, useMutation, useQuery, useSubscription } from 'urql'; import ImportCSVModal from '../../components/import-csv-modal'; import { ModalWorkflow } from '../../helpers/convert'; import { + UpdateDeviceInput, BulkInstallDevicesMutation, BulkInstallDevicesMutationVariables, DeleteDeviceMutation, @@ -59,6 +61,12 @@ import { DevicesConnectionSubscriptionVariables, DevicesConnectionSubscription, DiscoveryWorkflowsQuery, + LocationsQuery, + LocationsQueryVariables, + AddLocationMutation, + AddLocationMutationVariables, + UpdateDeviceMutation, + UpdateDeviceMutationVariables, } from '../../__generated__/graphql'; import BulkActions from './bulk-actions'; import DeleteSelectedDevicesModal from './delete-selected-modal'; @@ -66,7 +74,34 @@ import DeviceFilter from './device-filters'; import DeviceSearch from './device-search'; import DeviceTable from './device-table'; import WorkflowListModal from './workflow-list-modal'; -import LocationMapModal, { LocationModal } from '../../components/location-map-modal'; +import LocationMapModal, { LocationModal } from '../../components/edit-location-map-modal'; +import { LocationData } from '../create-device/create-device-page'; + +type UniconfigErrorItem = { + 'error-tag': string; // eslint-disable-line @typescript-eslint/naming-convention + 'error-info': Record; // eslint-disable-line @typescript-eslint/naming-convention + 'error-message': string; // eslint-disable-line @typescript-eslint/naming-convention + 'error-type': string; // eslint-disable-line @typescript-eslint/naming-convention +}; + +type UniconfigError = { + code: number; + message: { + errors: { + error: UniconfigErrorItem[]; + }; + }; +}; + +function parseErrorMessage(msg: string): string { + try { + const parsedError: UniconfigError = JSON.parse(msg); + + return parsedError.message.errors.error.map((e) => e['error-message']).join('\n'); + } catch (e) { + return 'unknown error'; + } +} const DEVICES_QUERY = gql` query Devices( @@ -121,6 +156,28 @@ const DEVICES_QUERY = gql` } } `; + +const UPDATE_DEVICE_MUTATION = gql` + mutation UpdateDevice($id: String!, $input: UpdateDeviceInput!) { + deviceInventory { + updateDevice(id: $id, input: $input) { + device { + id + name + model + vendor + address + isInstalled + zone { + id + name + } + } + } + } + } +`; + const INSTALL_DEVICE_MUTATION = gql` mutation InstallDevice($id: String!) { deviceInventory { @@ -253,6 +310,35 @@ const DISCOVERY_WORKFLOWS = gql` } `; +const LOCATIONS_QUERY = gql` + query Locations { + deviceInventory { + locations { + edges { + node { + id + latitude + longitude + name + } + } + } + } + } +`; + +const ADD_LOCATION_MUTATION = gql` + mutation AddLocation($addLocationInput: AddLocationInput!) { + deviceInventory { + addLocation(input: $addLocationInput) { + location { + id + } + } + } + } +`; + type SortedBy = 'name' | 'discoveredAt' | 'modelVersion'; type Direction = 'ASC' | 'DESC'; type Sorting = { @@ -302,6 +388,12 @@ const DeviceList: VoidFunctionComponent = () => { const [{ data: workflowsData }] = useQuery({ query: DISCOVERY_WORKFLOWS, }); + const [{ data: locationsData }] = useQuery({ + query: LOCATIONS_QUERY, + }); + + const [, addLocation] = useMutation(ADD_LOCATION_MUTATION); + const [, reconnectKafka] = useMutation( KAFKA_RECONNECT_MUTATION, ); @@ -309,6 +401,7 @@ const DeviceList: VoidFunctionComponent = () => { const [, uninstallDevice] = useMutation( UNINSTALL_DEVICE_MUTATION, ); + const [, updateDevice] = useMutation(UPDATE_DEVICE_MUTATION); const [, deleteDevice] = useMutation(DELETE_DEVICE_MUTATION); const [, executeWorkflow] = useMutation< ExecuteModalWorkflowByNameMutation, @@ -352,12 +445,34 @@ const DeviceList: VoidFunctionComponent = () => { const [isSendingToWorkflows, setIsSendingToWorkflows] = useState(false); const [selectedWorkflow, setSelectedWorkflow] = useState(null); - const [deviceToShowOnMap, setDeviceToShowOnMap] = useState(null); const kafkaHealthCheckToolbar = useDisclosure({ defaultIsOpen: true }); - const deviceColumnOptions = ['model/version', 'discoveredAt', 'deviceStatus', 'isInstalled']; + const deviceColumnOptions = [ + { name: 'model/version', value: 'model/version' }, + { name: 'discovered', value: 'discoveredAt' }, + { name: 'device status', value: 'deviceStatus' }, + { name: 'installation', value: 'isInstalled' }, + ]; + + const locationOptions = + locationsData?.deviceInventory.locations.edges.map(({ node: location }) => ({ + label: location.name, + value: location.name, + key: location.name, + })) || []; + + const locationsList = + locationsData?.deviceInventory.locations.edges.map((l) => { + const { id, name, latitude, longitude } = l.node; + return { + id, + name, + latitude, + longitude, + }; + }) || []; const handleCheckboxChange = (value: string) => { if (columnsDisplayed.includes(value)) { @@ -367,6 +482,16 @@ const DeviceList: VoidFunctionComponent = () => { } }; + const handleAddDeviceLocation = (locationData: LocationData) => { + addLocation({ + addLocationInput: locationData, + }); + }; + + const handleUpdateDeviceLocation = (id: string, updatedDeviceData: UpdateDeviceInput) => { + updateDevice({ id, input: updatedDeviceData }); + }; + useEffect(() => { let kafkaToolbarTimeout: NodeJS.Timeout; @@ -409,14 +534,20 @@ const DeviceList: VoidFunctionComponent = () => { content: 'Device uninstalled successfuly', }); } - if (res.error) { - addToastNotification({ - type: 'error', - title: 'Error', - content: 'Uninstallation failed', - }); + if (res.error != null) { + const message = res.error.graphQLErrors.at(0)?.message ?? ''; + throw new Error(parseErrorMessage(message)); } }) + .catch((e) => { + addToastNotification({ + type: 'error', + title: 'Error', + timeout: 10000, + content: `Uninstallation failed:\n\n + ${e}`, + }); + }) .finally(() => { setInstallLoadingMap((m) => { return { @@ -490,14 +621,18 @@ const DeviceList: VoidFunctionComponent = () => { }); } if (res.error != null) { - throw new Error(res.error?.message); + const message = parseErrorMessage(res.error.graphQLErrors[0].message); + throw new Error(message); } }) - .catch(() => { + .catch((e) => { addToastNotification({ type: 'error', title: 'Error', - content: 'Installation failed', + timeout: 10, + content: `Installation failed + ${e} + `, }); }) .finally(() => { @@ -529,7 +664,8 @@ const DeviceList: VoidFunctionComponent = () => { }) .then((res) => { if (res.error != null || res.data == null) { - throw new Error(res.error?.message ?? 'Problem with bulk installation of devices'); + const bulkErrors = res.error?.graphQLErrors.map((e) => parseErrorMessage(e.message)) ?? []; + throw new Error(bulkErrors.join('\n')); } if (res.data?.deviceInventory.bulkInstallDevices.installedDevices.length === 0) { @@ -542,11 +678,13 @@ const DeviceList: VoidFunctionComponent = () => { content: 'Devices installed successfuly', }); }) - .catch(() => { + .catch((e) => { addToastNotification({ type: 'error', title: 'Error', - content: 'Bulk installation of devices has failed', + timeout: 10, + content: `Bulk installation of devices has failed + ${e.message}`, }); }) .finally(() => { @@ -692,9 +830,7 @@ const DeviceList: VoidFunctionComponent = () => { /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable object-shorthand */ - ips_and_ports: { - ips: ips.map((ip) => ({ ip: ip })), - }, + ips: ips.filter(omitNullValue), }; /* eslint-enable object-shorthand */ /* eslint-enable @typescript-eslint/naming-convention */ @@ -818,7 +954,11 @@ const DeviceList: VoidFunctionComponent = () => { )} {deviceToShowOnMap != null && ( { setDeviceToShowOnMap(null); }} @@ -888,9 +1028,12 @@ const DeviceList: VoidFunctionComponent = () => { {deviceColumnOptions.map((option) => ( - - handleCheckboxChange(option)}> - {option} + + handleCheckboxChange(option.value)} + > + {option.name} ))} diff --git a/packages/frinx-inventory-client/src/pages/device-list/device-table.tsx b/packages/frinx-inventory-client/src/pages/device-list/device-table.tsx index 8f31591b3..d8cfc9948 100644 --- a/packages/frinx-inventory-client/src/pages/device-list/device-table.tsx +++ b/packages/frinx-inventory-client/src/pages/device-list/device-table.tsx @@ -22,7 +22,7 @@ import { getDeviceUsageColor, getLocalDateFromUTC, getDeviceUsage, omitNullValue import { DevicesQuery, DevicesUsage } from '../../__generated__/graphql'; import InstallButton from './install-button'; import { isDeviceOnUniconfigLayer } from '../../helpers/device'; -import { LocationModal } from '../../components/location-map-modal'; +import { LocationModal } from '../../components/edit-location-map-modal'; type SortedBy = 'name' | 'discoveredAt' | 'modelVersion'; type Direction = 'ASC' | 'DESC'; @@ -278,10 +278,9 @@ const DeviceTable: VoidFunctionComponent = ({ data-cy={`device-map-${device.name}`} aria-label="map" size="sm" - isDisabled={!device.location || !device.location.latitude || !device.location.longitude} icon={} - as={isInstalled ? Link : 'button'} - onClick={() => onDeviceMapBtnClick({ title: device.name, location: device.location })} + as={Link} + onClick={() => onDeviceMapBtnClick({ location: device.location, deviceId: device.id })} /> { const context = useMemo(() => ({ additionalTypenames: ['Location'] }), []); - const [paginationArgs, { nextPage, previousPage }] = usePagination(); + const [paginationArgs, { nextPage, previousPage, firstPage }] = usePagination(); + const [searchNameText, setSearchNameText] = useState(''); + const [locationNameFilter, setLocationNameFilter] = useState(null); + const [orderBy, setOrderBy] = useState({ sortKey: 'name', direction: 'ASC' }); const [{ data: locationQData, error }] = useQuery({ query: LOCATION_LIST_QUERY, variables: { + filter: { name: locationNameFilter }, + orderBy, ...paginationArgs, }, context, @@ -118,14 +137,14 @@ const LocationList: VoidFunctionComponent = () => { DELETE_LOCATION_MUTATION, ); - const [locationToShowOnMap, setLocationToShowOnMap] = useState(null); + const [locationToShowOnMap, setLocationToShowOnMap] = useState(null); const addLocationModalDisclosure = useDisclosure(); const editLocationModalDisclosure = useDisclosure(); const deleteModalDisclosure = useDisclosure(); const [locationIdToDelete, setLocationIdToDelete] = useState(null); const [locationToEdit, setLocationToEdit] = useState(); - const handleMapBtnClick = (deviceLocation: LocationModal | null) => { + const handleMapBtnClick = (deviceLocation: ViewLocationModal | null) => { setLocationToShowOnMap(deviceLocation); }; @@ -166,6 +185,27 @@ const LocationList: VoidFunctionComponent = () => { deleteModalDisclosure.onClose(); }; + const handleSort = (sortKey: SortLocationBy) => { + return orderBy.direction === 'DESC' + ? setOrderBy({ sortKey, direction: 'ASC' }) + : setOrderBy({ sortKey, direction: 'DESC' }); + }; + + const handleChange = (event: ChangeEvent) => { + setSearchNameText(event.target.value); + }; + + const clearFilter = () => { + setSearchNameText(''); + setLocationNameFilter(null); + }; + + const handleSearchSubmit = (e: FormEvent) => { + e.preventDefault(); + firstPage(); + setLocationNameFilter(searchNameText); + }; + if (locationQData == null || error != null) { return null; } @@ -174,7 +214,7 @@ const LocationList: VoidFunctionComponent = () => { return ( <> {locationToShowOnMap != null && ( - { setLocationToShowOnMap(null); @@ -217,11 +257,58 @@ const LocationList: VoidFunctionComponent = () => { +
+ + + Search by name: + + + + + + + +
- + diff --git a/packages/frinx-workflow-ui/src/components/modals/create-schedule-workflow-modal.tsx b/packages/frinx-workflow-ui/src/components/modals/create-schedule-workflow-modal.tsx new file mode 100644 index 000000000..8f0af3901 --- /dev/null +++ b/packages/frinx-workflow-ui/src/components/modals/create-schedule-workflow-modal.tsx @@ -0,0 +1,284 @@ +import React, { FC, useEffect, useState } from 'react'; +import { + Box, + Button, + Checkbox, + FormControl, + FormErrorMessage, + FormHelperText, + FormLabel, + Grid, + GridItem, + HStack, + Icon, + Input, + Link, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Spacer, + Text, + useBoolean, +} from '@chakra-ui/react'; +import { + parseInputParameters, + getDynamicInputParametersFromWorkflow, + ClientWorkflow, + getInitialValuesFromParsedInputParameters, + CreateScheduledWorkflow, + ExecuteWorkflowModalFormInput, + Autocomplete, + InputParameter, +} from '@frinx/shared'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; +import moment from 'moment'; +import FeatherIcon from 'feather-icons-react'; +import { Item } from '@frinx/shared/dist/components/autocomplete/autocomplete'; +import { CreateScheduleInput } from '../../__generated__/graphql'; + +const DEFAULT_CRON_STRING = '* * * * *'; +const CRON_REGEX = + /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/; + +type Props = { + workflows: ClientWorkflow[]; + isOpen: boolean; + onClose: () => void; + onSubmit: (workflow: CreateScheduleInput) => void; +}; + +const CreateScheduleWorkflowModal: FC = ({ workflows, isOpen, onClose, onSubmit }) => { + const validationSchema = Yup.object().shape({ + workflowName: Yup.string().required('Workflow name is required'), + workflowVersion: Yup.string().required('Workflow version is required'), + name: Yup.string().required('Schedule name is required'), + cronString: Yup.string() + .required('Cron expression is required') + .test({ + name: 'cronString', + message: 'Cron expression is invalid', + test: (value) => { + if (!value) { + return true; + } else { + return CRON_REGEX.test(value); + } + }, + }), + enabled: Yup.boolean(), + workflowContext: Yup.object().required('Workflow is required'), + fromDate: Yup.string(), + toDate: Yup.string(), + }); + + const [selectedWorkflow, setSelectedWorkflow] = useState(); + + const [dynamicInputParameters, setDynamicInputParameters] = useState(null); + const [parsedInputParameters, setParsedInputParameters] = useState(null); + + const [shouldShowInputParams, { toggle: toggleShouldShowInputParams }] = useBoolean(false); + + const { values, errors, handleChange, submitForm, setFieldValue, resetForm } = useFormik({ + // enableReinitialize: true, + validationSchema, + validateOnMount: false, + initialValues: { + workflowName: '', + workflowVersion: '', + workflowContext: {}, + name: '', + cronString: DEFAULT_CRON_STRING, + enabled: false, + fromDate: undefined, + toDate: undefined, + }, + onSubmit: (formValues) => { + const formattedValues: CreateScheduleInput = { + ...formValues, + workflowContext: JSON.stringify(formValues.workflowContext), + cronString: formValues.cronString || DEFAULT_CRON_STRING, + ...(formValues.fromDate && { + fromDate: moment(formValues.fromDate).format('yyyy-MM-DDTHH:mm:ss.SSSZ'), + }), + ...(formValues.toDate && { + toDate: moment(formValues.toDate).format('yyyy-MM-DDTHH:mm:ss.SSSZ'), + }), + }; + + onSubmit(formattedValues); + resetForm(); + onClose(); + }, + }); + + useEffect(() => { + setFieldValue('workflowName', selectedWorkflow?.name); + setFieldValue('workflowVersion', selectedWorkflow?.version.toString() ?? ''); + setDynamicInputParameters(getDynamicInputParametersFromWorkflow(selectedWorkflow)); + setParsedInputParameters(parseInputParameters(selectedWorkflow?.inputParameters)); + }, [selectedWorkflow, setFieldValue]); + + useEffect(() => { + setFieldValue( + 'workflowContext', + getInitialValuesFromParsedInputParameters(parsedInputParameters, dynamicInputParameters), + ); + }, [dynamicInputParameters, parsedInputParameters, setFieldValue]); + + const getCrontabGuruUrl = () => { + const cronString = values.cronString || DEFAULT_CRON_STRING; + const url = `https://crontab.guru/#${cronString.replace(/\s/g, '_')}`; + return ( + + crontab.guru + + ); + }; + + const handleWorkflowChanged = (item: Item | null | undefined) => { + const workflowToSelect = workflows.find((workflow) => workflow.name === item?.value); + setSelectedWorkflow(workflowToSelect); + }; + + const workflowOptions = workflows.map((workflow) => ({ + label: workflow.name, + value: workflow.name, + })); + + const inputParametersKeys = Object.keys(values.workflowContext); + + return ( + + + + + Schedule Details - {values.workflowName}:{values.workflowVersion} + + + + + Schedule name + { + handleChange(e); + }} + name="name" + placeholder="Enter name for scheduled workflow" + /> + {errors.name} + + + + Workflow + + {errors.workflowName} + + + + + From + + {errors.fromDate} + + + + To + + {errors.toDate} + + + + + + Enabled + + Enabled + + {errors.enabled} + + + + Cron Expression + + Verify using {getCrontabGuruUrl()} + {errors.cronString} + + + + + Show input parameters + + + + + + + + + + + + + + ); +}; + +export default CreateScheduleWorkflowModal; diff --git a/packages/frinx-workflow-ui/src/components/modals/edit-schedule-workflow-modal.tsx b/packages/frinx-workflow-ui/src/components/modals/edit-schedule-workflow-modal.tsx index 53966faa1..dd0cf111c 100644 --- a/packages/frinx-workflow-ui/src/components/modals/edit-schedule-workflow-modal.tsx +++ b/packages/frinx-workflow-ui/src/components/modals/edit-schedule-workflow-modal.tsx @@ -38,7 +38,8 @@ import moment from 'moment'; import FeatherIcon from 'feather-icons-react'; const DEFAULT_CRON_STRING = '* * * * *'; -const CRON_REGEX = /^(\*|[0-5]?\d)(\s(\*|[01]?\d|2[0-3])){2}(\s(\*|[1-9]|[12]\d|3[01])){2}$/; +const CRON_REGEX = + /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/; type Props = { scheduledWorkflow: CreateScheduledWorkflow; diff --git a/packages/frinx-workflow-ui/src/components/modals/schedule-workflow-modal.tsx b/packages/frinx-workflow-ui/src/components/modals/schedule-workflow-modal.tsx index d514c5f8c..81c54b4de 100644 --- a/packages/frinx-workflow-ui/src/components/modals/schedule-workflow-modal.tsx +++ b/packages/frinx-workflow-ui/src/components/modals/schedule-workflow-modal.tsx @@ -54,7 +54,9 @@ const SCHEDULED_WORKFLOWS_QUERY = gql` `; const DEFAULT_CRON_STRING = '* * * * *'; -const CRON_REGEX = /^(\*|[0-5]?\d)(\s(\*|[01]?\d|2[0-3])){2}(\s(\*|[1-9]|[12]\d|3[01])){2}$/; +const CRON_REGEX = + /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/; + function getCrontabGuruUrl(cronString: string = DEFAULT_CRON_STRING) { return `https://crontab.guru/#${cronString.replace(/\s/g, '_')}`; } diff --git a/packages/frinx-workflow-ui/src/pages/scheduled-workflow/scheduled-workflow-list.tsx b/packages/frinx-workflow-ui/src/pages/scheduled-workflow/scheduled-workflow-list.tsx index b106ba7bb..163cc762d 100644 --- a/packages/frinx-workflow-ui/src/pages/scheduled-workflow/scheduled-workflow-list.tsx +++ b/packages/frinx-workflow-ui/src/pages/scheduled-workflow/scheduled-workflow-list.tsx @@ -18,12 +18,23 @@ import { Container, Flex, IconButton, + Button, } from '@chakra-ui/react'; import FeatherIcon from 'feather-icons-react'; -import { omitNullValue, useNotifications, StatusType, ClientWorkflow, CreateScheduledWorkflow } from '@frinx/shared'; +import { + omitNullValue, + useNotifications, + StatusType, + ClientWorkflow, + CreateScheduledWorkflow, + unwrap, +} from '@frinx/shared'; import { sortBy } from 'lodash'; import { gql, useQuery, useMutation } from 'urql'; import { + CreateScheduleInput, + CreateScheduleMutation, + CreateScheduleMutationVariables, DeleteScheduleMutation, DeleteScheduleMutationVariables, SchedulesQuery, @@ -32,6 +43,7 @@ import { WorkflowListQuery, } from '../../__generated__/graphql'; import EditScheduleWorkflowModal from '../../components/modals/edit-schedule-workflow-modal'; +import CreateScheduleWorkflowModal from '../../components/modals/create-schedule-workflow-modal'; const WORKFLOWS_QUERY = gql` query WorkflowList { @@ -97,6 +109,23 @@ const SCHEDULED_WORKFLOWS_QUERY = gql` } `; +const CREATE_SCHEDULE_MUTATION = gql` + mutation CreateSchedule($input: CreateScheduleInput!) { + scheduler { + createSchedule(input: $input) { + name + enabled + workflowName + workflowVersion + cronString + workflowContext + fromDate + toDate + } + } + } +`; + const DELETE_SCHEDULE_MUTATION = gql` mutation DeleteSchedule($name: String!) { scheduler { @@ -138,6 +167,7 @@ function getStatusTagColor(status: StatusType) { function ScheduledWorkflowList() { const context = useMemo(() => ({ additionalTypenames: ['Schedule'] }), []); const [selectedWorkflow, setSelectedWorkflow] = useState(); + const createScheduleDisclosure = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure(); const { addToastNotification } = useNotifications(); @@ -148,6 +178,9 @@ function ScheduledWorkflowList() { query: SCHEDULED_WORKFLOWS_QUERY, requestPolicy: 'cache-and-network', }); + const [, createSchedule] = useMutation( + CREATE_SCHEDULE_MUTATION, + ); const [, deleteSchedule] = useMutation( DELETE_SCHEDULE_MUTATION, ); @@ -155,11 +188,48 @@ function ScheduledWorkflowList() { UPDATE_SCHEDULE_MUTATION, ); + const handleOnCreateClick = () => { + createScheduleDisclosure.onOpen(); + }; + const handleOnEditClick = (workflow: CreateScheduledWorkflow) => { setSelectedWorkflow(workflow); onOpen(); }; + const handleCreateWorkflow = (scheduledWf: CreateScheduleInput) => { + const scheduleInput = { + ...scheduledWf, + cronString: unwrap(scheduledWf.cronString), + }; + if (scheduledWf.workflowName != null && scheduledWf.workflowVersion != null) { + createSchedule({ input: scheduleInput }) + .then((res) => { + if (!res.data?.scheduler.createSchedule) { + addToastNotification({ + type: 'error', + title: 'Error', + content: res.error?.message, + }); + } + if (res.data?.scheduler.createSchedule || !res.error) { + addToastNotification({ + content: 'Successfully scheduled', + title: 'Success', + type: 'success', + }); + } + }) + .catch(() => { + addToastNotification({ + type: 'error', + title: 'Error', + content: 'Failed to schedule workflow', + }); + }); + } + }; + const handleWorkflowUpdate = ({ workflowName, workflowVersion, ...scheduledWf }: CreateScheduledWorkflow) => { const { cronString, enabled, fromDate, toDate, workflowContext } = scheduledWf; const input = { @@ -279,7 +349,21 @@ function ScheduledWorkflowList() { Scheduled workflows + + + + { + createScheduleDisclosure.onClose(); + }} + onSubmit={handleCreateWorkflow} + /> + {selectedWorkflow != null && selectedClientWorkflow != null && ( ; @@ -89,8 +80,8 @@ const WorkflowFormInput: VoidFunctionComponent = ({ onChange(inputParameterKey, JSON.stringify(selectedTags))} + selectedTags={values[inputParameterKey]} + onSelectionChange={(selectedTags) => onChange(inputParameterKey, selectedTags ?? [])} /> )} diff --git a/public/index.html b/public/index.html index c3780e40b..6e6e8a888 100644 --- a/public/index.html +++ b/public/index.html @@ -17,7 +17,6 @@
-
Name handleSort('name')}> + Name + {orderBy.sortKey === 'name' && ( + + )} + Created Updated Latitude