From 59a54e17c6c5f41f8b4c8dd73e5f295523b7e0d6 Mon Sep 17 00:00:00 2001 From: Simon Rho Date: Mon, 5 Aug 2024 04:12:20 -0400 Subject: [PATCH] Added a setting to override outbound SSH config and fixed device count issue. --- jccm/package.json | 2 +- jccm/src/Frontend/Common/StateStore.js | 22 +- jccm/src/Frontend/Layout/ChangeIcon.js | 2 +- jccm/src/Frontend/Layout/Devices.js | 8 +- jccm/src/Frontend/Layout/Footer.js | 49 ++- .../Layout/GlobalSettings/GeneralCard.js | 62 +++- .../Frontend/Layout/InventoryLocalEditForm.js | 4 +- .../Frontend/Layout/InventoryTreeMenuLocal.js | 287 ++++++++++++++---- jccm/src/Frontend/MainEventProcessor.js | 11 +- jccm/src/Services/ApiServer.js | 10 +- 10 files changed, 377 insertions(+), 80 deletions(-) diff --git a/jccm/package.json b/jccm/package.json index 6a14312..95f033b 100644 --- a/jccm/package.json +++ b/jccm/package.json @@ -1,7 +1,7 @@ { "name": "jccm", "productName": "Juniper Cloud Connection Manager", - "version": "1.1.0", + "version": "1.1.1", "description": "Juniper Cloud Connection Manager", "main": ".webpack/main", "scripts": { diff --git a/jccm/src/Frontend/Common/StateStore.js b/jccm/src/Frontend/Common/StateStore.js index 4bc248f..a640024 100644 --- a/jccm/src/Frontend/Common/StateStore.js +++ b/jccm/src/Frontend/Common/StateStore.js @@ -67,9 +67,9 @@ const useStore = create((set, get) => ({ const orgs = {}; user?.privileges.forEach((item) => { if (item.scope === 'org') { - const orgId = item.org_id + const orgId = item.org_id; const orgName = item.name; - orgs[orgId] = orgName; + orgs[orgId] = orgName; } }); return { user, orgs }; @@ -332,6 +332,24 @@ const useStore = create((set, get) => ({ }; }), + cleanUpDeviceFacts: async () => { + const state = get(); + const inventoryPaths = new Set(state.inventory.map((item) => item._path)); + const cleanedDeviceFacts = Object.fromEntries( + Object.entries(state.deviceFacts).filter(([key]) => inventoryPaths.has(key)) + ); + + console.log('inventoryPaths', inventoryPaths); + console.log('state.deviceFacts', state.deviceFacts); + console.log('cleanedDeviceFacts', cleanedDeviceFacts); + + await electronAPI.saSaveDeviceFacts({ facts: cleanedDeviceFacts }); + + set(() => ({ + deviceFacts: cleanedDeviceFacts, + })); + }, + deleteDeviceFacts: (path) => set((state) => { const { [path]: _, ...rest } = state.deviceFacts; diff --git a/jccm/src/Frontend/Layout/ChangeIcon.js b/jccm/src/Frontend/Layout/ChangeIcon.js index 4f1df0b..4ecc4c6 100644 --- a/jccm/src/Frontend/Layout/ChangeIcon.js +++ b/jccm/src/Frontend/Layout/ChangeIcon.js @@ -37,7 +37,7 @@ export const CircleIcon = ({ Icon, color = tokens.colorPaletteGreenBorder2, size width: `calc(${size} + 2px)`, height: `calc(${size} + 2px)`, borderRadius: '50%', - border: `2px solid ${color}`, + border: `0.5px solid ${color}`, }} > diff --git a/jccm/src/Frontend/Layout/Devices.js b/jccm/src/Frontend/Layout/Devices.js index 8a78172..6c96208 100644 --- a/jccm/src/Frontend/Layout/Devices.js +++ b/jccm/src/Frontend/Layout/Devices.js @@ -1,8 +1,8 @@ const { electronAPI } = window; -export const adoptDevices = async (device, jsiTerm=false) => { +export const adoptDevices = async (device, jsiTerm=false, deleteOutboundSSHTerm=false) => { const { address, port, username, password, organization, site } = device; - const response = await electronAPI.saAdoptDevice({ address, port, username, password, organization, site, jsiTerm }); + const response = await electronAPI.saAdoptDevice({ address, port, username, password, organization, site, jsiTerm, deleteOutboundSSHTerm }); if (response.adopt) { return { status: true, result: response.result }; @@ -12,8 +12,8 @@ export const adoptDevices = async (device, jsiTerm=false) => { } }; -export const releaseDevices = async (device, serialNumber) => { - const { organization } = device; +export const releaseDevices = async (deviceInfo) => { + const { organization, serialNumber} = deviceInfo; const response = await electronAPI.saReleaseDevice({ organization, serial: serialNumber }); if (response.release) { diff --git a/jccm/src/Frontend/Layout/Footer.js b/jccm/src/Frontend/Layout/Footer.js index ca2593a..d2170fd 100644 --- a/jccm/src/Frontend/Layout/Footer.js +++ b/jccm/src/Frontend/Layout/Footer.js @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { Label, Button, tokens } from '@fluentui/react-components'; import { HexagonThreeRegular, HexagonThreeFilled, bundleIcon } from '@fluentui/react-icons'; @@ -7,12 +7,49 @@ import useStore from '../Common/StateStore'; import { BastionHostButton } from './BastionHostButton'; export default () => { - const { inventory, deviceFacts, cloudDevices } = useStore(); + const { isUserLoggedIn, inventory, deviceFacts, cloudDevices, cloudInventory } = useStore(); + const [countOfOrgOrSiteUnmatched, setCountOfOrgOrSiteUnmatched] = useState(0); + const countOfDeviceFacts = Object.keys(deviceFacts).length; const countOfAdoptedDevices = Object.values(deviceFacts).filter( (facts) => cloudDevices[facts?.serialNumber] ).length; + const doesSiteNameExist = (orgName, siteName) => { + const org = cloudInventory.find((item) => item.name === orgName); + + // If the organization is not found, return false + if (!org) { + return false; + } + + // Check if the site name exists within the organization's sites array + const siteExists = org.sites.some((site) => site.name === siteName); + + return siteExists; + }; + + const countNonMatchingInventoryItems = () => { + let nonMatchingCount = 0; + inventory.forEach((item) => { + const existence = doesSiteNameExist(item.organization, item.site); + if (!existence) { + nonMatchingCount++; + } + }); + + return nonMatchingCount; + } + + useEffect(() => { + const timer = setTimeout(() => { + const count = countNonMatchingInventoryItems(); + setCountOfOrgOrSiteUnmatched(count); + }, 3000); // 3 seconds delay + + return () => clearTimeout(timer); // Cleanup timer on component unmount + }, [inventory, cloudDevices]); + return (
{ > Adopted Devices: {countOfAdoptedDevices} + {isUserLoggedIn && countOfOrgOrSiteUnmatched > 0 && ( + + )}
); diff --git a/jccm/src/Frontend/Layout/GlobalSettings/GeneralCard.js b/jccm/src/Frontend/Layout/GlobalSettings/GeneralCard.js index 148e0d6..31d52d6 100644 --- a/jccm/src/Frontend/Layout/GlobalSettings/GeneralCard.js +++ b/jccm/src/Frontend/Layout/GlobalSettings/GeneralCard.js @@ -39,6 +39,7 @@ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export const GeneralCard = () => { const { settings, setSettings, importSettings, exportSettings } = useStore(); const [jsiTerm, setJsiTerm] = useState(false); + const [deleteOutboundSSHTerm, setDeleteOutboundSSHTerm] = useState(false); const [windowSize, setWindowSize] = useState({ width: window.innerWidth, @@ -49,7 +50,10 @@ export const GeneralCard = () => { const fetchData = async () => { importSettings(); await delay(300); + + console.log('importing settings', settings); setJsiTerm(settings?.jsiTerm ? true : false); + setDeleteOutboundSSHTerm(settings?.deleteOutboundSSHTerm ? true : false); }; fetchData(); }, []); @@ -63,15 +67,38 @@ export const GeneralCard = () => { saveFunction(); }; - const handleActive = async (event) => { + const saveDeleteOutboundSSHTerm = (newDeleteOutboundSSHTerm) => { + const saveFunction = async () => { + const newSettings = { ...settings, deleteOutboundSSHTerm: newDeleteOutboundSSHTerm }; + setSettings(newSettings); + exportSettings(newSettings); + }; + saveFunction(); + }; + + const onChangeJsiTerm = async (event) => { const checked = event.currentTarget.checked; setJsiTerm(checked); - saveJsiTerm(checked); }; + const onChangeDeleteOutboundSSHTerm = async (event) => { + const checked = event.currentTarget.checked; + setDeleteOutboundSSHTerm(checked); + saveDeleteOutboundSSHTerm(checked); + }; + return ( -
+
{ >
{jsiTerm ? 'Enabled' : 'Disabled'}
+ +
+ Override outbound SSH config during adoption: + +
+ +
+ {deleteOutboundSSHTerm ? 'Enabled' : 'Disabled'} +
); }; diff --git a/jccm/src/Frontend/Layout/InventoryLocalEditForm.js b/jccm/src/Frontend/Layout/InventoryLocalEditForm.js index 126b211..8c87d7c 100644 --- a/jccm/src/Frontend/Layout/InventoryLocalEditForm.js +++ b/jccm/src/Frontend/Layout/InventoryLocalEditForm.js @@ -51,6 +51,7 @@ const { electronAPI } = window; import * as Constants from '../Common/CommonVariables'; import useStore from '../Common/StateStore'; import { useNotify } from '../Common/NotificationContext'; +import eventBus from '../Common/eventBus'; const Dismiss = bundleIcon(DismissFilled, DismissRegular); const AddCircle = bundleIcon(AddCircleFilled, AddCircleRegular); @@ -186,7 +187,8 @@ const InventoryLocalEditForm = ({ isOpen, onClose, title, importedInventory }) = const onSave = async () => { setInventory(rowData); await electronAPI.saSetLocalInventory({ inventory: rowData }); - setTimeout(() => { + setTimeout(async () => { + await eventBus.emit('device-facts-cleanup', { notification: false }); onClose(); }, 300); }; diff --git a/jccm/src/Frontend/Layout/InventoryTreeMenuLocal.js b/jccm/src/Frontend/Layout/InventoryTreeMenuLocal.js index c3723b2..6687633 100644 --- a/jccm/src/Frontend/Layout/InventoryTreeMenuLocal.js +++ b/jccm/src/Frontend/Layout/InventoryTreeMenuLocal.js @@ -106,7 +106,6 @@ import { RenameFilled, DeleteDismissRegular, DeleteDismissFilled, - WarningRegular, CutRegular, CutFilled, ClipboardPasteRegular, @@ -162,6 +161,13 @@ import { BoxToolboxRegular, EqualOffRegular, EqualOffFilled, + FlagOffFilled, + OrganizationFilled, + WarningFilled, + WarningRegular, + ErrorCircleRegular, + FlagFilled, + WeatherThunderstormRegular, bundleIcon, } from '@fluentui/react-icons'; import _ from 'lodash'; @@ -321,7 +327,7 @@ const convertToFlatTreeItems = (localInventory) => { const InventoryTreeMenuLocal = () => { const { showContextMenu } = useContextMenu(); const { notify } = useNotify(); - const { isUserLoggedIn, settings } = useStore(); + const { isUserLoggedIn, orgs, settings } = useStore(); const { tabs, addTab, setSelectedTabValue, adoptConfig, inventory, setInventory } = useStore(); const { cloudInventory, setCloudInventory, setCloudInventoryFilterApplied, cloudDevices } = useStore(); @@ -391,11 +397,10 @@ const InventoryTreeMenuLocal = () => { const [isSiteMatch, setIsSiteMatch] = useState(true); useEffect(() => { - // const isFact = !!device?.facts; const isFact = !!deviceFacts[device._path]; - const adopted = isFact ? !!cloudDevices[deviceFacts[device._path].serialNumber] : false; + const adopted = isFact ? !!cloudDevices[deviceFacts[device._path]?.serialNumber] : false; if (adopted) { - const cloudDevice = cloudDevices[deviceFacts[device._path].serialNumber]; + const cloudDevice = cloudDevices[deviceFacts[device._path]?.serialNumber]; const cloudOrgName = cloudDevice.org_name; const cloudSiteName = cloudDevice.site_name; const deviceOrgName = device.orgName; @@ -403,57 +408,130 @@ const InventoryTreeMenuLocal = () => { setIsOrgMatch(cloudOrgName === deviceOrgName); setIsSiteMatch(cloudSiteName === deviceSiteName); - - if (cloudOrgName !== deviceOrgName) { - console.log('>>>device is not adopted to same org', cloudDevice, device); - } - - if (cloudSiteName !== deviceSiteName) { - console.log('>>>device is not adopted to same site', cloudDevice, device); - } - - // console.log('device', device); - // console.log('cloudDevices', cloudDevices); - // console.log('adopted: cloudDevices: ', cloudDevices[deviceFacts[device._path].serialNumber]) - // console.log('adopted: deviceFacts: ', deviceFacts[device._path]) } setIsAdopted(adopted); }, [cloudDevices, device]); + const IconWithTooltip = ({ content, relationship, Icon, size, color }) => ( + +
+ +
+
+ ); + + const CircleIconWithTooltip = ({ content, relationship, Icon, color, size = '10px' }) => ( + +
+ +
+
+ ); + + const orgMismatchContent = ( +
+ + The organization name ({device.orgName}) does not exist in your account: + + + {`"${device.orgName}" ≠ "${cloudDevices[deviceFacts[device._path]?.serialNumber]?.org_name}"`} + +
+ ); + + const siteMismatchContent = ( +
+ + The site name ({device.siteName}) does not exist in your account: + + + {`"${device.siteName}" ≠ "${cloudDevices[deviceFacts[device._path]?.serialNumber]?.site_name}"`} + +
+ ); + const getIcon = () => { if (isAdopted) { return ( - - {renderObjectValue(cloudDevices[deviceFacts[device._path].serialNumber])} - - } - relationship='description' - withArrow - positioning='above-end' +
- {isOpen ? ( - - ) : ( -
+ ) : !isSiteMatch ? ( + + ) : null} + + + {renderObjectValue(cloudDevices[deviceFacts[device._path]?.serialNumber])} +
+ } + relationship='description' + withArrow + positioning='above-end' + > + {isOpen ? ( + + ) : ( - {/* {!isOrgMatch && ( - - )} */} -
- )} -
+ )} + + ); } else { return isOpen ? ( @@ -728,14 +806,14 @@ const InventoryTreeMenuLocal = () => { await electronAPI.saSaveDeviceFacts({ facts: deviceFactsRef.current }); }; - const actionAdoptDevice = async (device, jsiTerm = false) => { + const actionAdoptDevice = async (device, jsiTerm = false, deleteOutboundSSHTerm = false) => { const maxRetries = 6; const retryInterval = 15 * 1000; // 15 seconds in milliseconds setIsAdopting(device._path, { status: true, retry: 0 }); for (let attempt = 1; attempt <= maxRetries; attempt++) { - const result = await adoptDevices(device, jsiTerm); + const result = await adoptDevices(device, jsiTerm, deleteOutboundSSHTerm); if (result.status) { setTimeout(async () => { const fetchAndUpdateCloudInventory = async () => { @@ -784,9 +862,15 @@ const InventoryTreeMenuLocal = () => { }; }); - const targetDevices = inventoryWithPath.filter( - (device) => device.path.startsWith(node.value) && !!!cloudDevices[device?.facts?.serialNumber] - ); + const targetDevices = inventoryWithPath.filter((device) => { + const orgName = device.organization; + const siteName = device.site; + + const siteExists = doesSiteNameExist(orgName, siteName); + const serialNumber = deviceFacts[device.path]?.serialNumber; + + return siteExists && device.path.startsWith(node.value) && !!!cloudDevices[serialNumber]; + }); const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const rateLimit = 1000 / rate; // Rate in calls per second @@ -798,7 +882,7 @@ const InventoryTreeMenuLocal = () => { const executeCall = async (device) => { const promise = new Promise(async (resolve) => { - await actionAdoptDevice(device, jsiTerm); + await actionAdoptDevice(device, jsiTerm, settings.deleteOutboundSSHTerm); resolve(); }).then(() => { runningCalls--; @@ -828,9 +912,10 @@ const InventoryTreeMenuLocal = () => { const actionReleaseDevice = async (device) => { setIsReleasing(device.path, true); - const serialNumber = deviceFacts[device._path].serialNumber; + const serialNumber = deviceFacts[device.path]?.serialNumber; + const organization = cloudDevices[serialNumber]?.org_name; - const result = await releaseDevices(device, serialNumber); + const result = await releaseDevices({ organization, serialNumber }); if (result.status) { setTimeout(async () => { const fetchAndUpdateCloudInventory = async () => { @@ -871,9 +956,10 @@ const InventoryTreeMenuLocal = () => { }; }); - const targetDevices = inventoryWithPath.filter( - (device) => device.path.startsWith(node.value) && !!cloudDevices[deviceFacts[device._path].serialNumber] - ); + const targetDevices = inventoryWithPath.filter((device) => { + const serialNumber = deviceFacts[device.path]?.serialNumber; + return device.path.startsWith(node.value) && !!cloudDevices[serialNumber]; + }); const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const rateLimit = 1000 / rate; // Rate in calls per second @@ -916,7 +1002,7 @@ const InventoryTreeMenuLocal = () => { const devices = inventory.filter( (device) => device._path.startsWith(node.value) && !!deviceFacts[device._path] ); - const devicesAdopted = devices.filter((device) => !!cloudDevices[deviceFacts[device._path].serialNumber]); + const devicesAdopted = devices.filter((device) => !!cloudDevices[deviceFacts[device._path]?.serialNumber]); const isTargetDeviceAvailable = () => { return devices.length > 0; @@ -926,6 +1012,26 @@ const InventoryTreeMenuLocal = () => { return devicesAdopted.length > 0; }; + const isOrgSiteMatch = () => { + const splitParentValue = node.parentValue ? node.parentValue.split('/') : []; + + switch (node.type) { + case 'root': + return true; + case 'org': + return doesOrgNameExist(node.content); + case 'site': + return doesSiteNameExist(splitParentValue.pop(), node.content); + case 'device': + return doesSiteNameExist( + splitParentValue[splitParentValue.length - 2], + splitParentValue[splitParentValue.length - 1] + ); + default: + return false; + } + }; + return ( @@ -952,7 +1058,7 @@ const InventoryTreeMenuLocal = () => { )} } onClick={async () => { actionAdoptDevices(node); @@ -968,7 +1074,7 @@ const InventoryTreeMenuLocal = () => { {settings.jsiTerm && ( } onClick={async () => { actionAdoptDevices(node, true); @@ -1024,6 +1130,30 @@ const InventoryTreeMenuLocal = () => { }); const treeProps = flatTree.getTreeProps(); + const doesOrgNameExist = (orgName) => { + // Iterate over the values of the orgs object + for (const name of Object.values(orgs)) { + if (name === orgName) { + return true; // Return true if the orgName exists + } + } + return false; // Return false if the orgName does not exist + }; + + const doesSiteNameExist = (orgName, siteName) => { + const org = cloudInventory.find((item) => item.name === orgName); + + // If the organization is not found, return false + if (!org) { + return false; + } + + // Check if the site name exists within the organization's sites array + const siteExists = org.sites.some((site) => site.name === siteName); + + return siteExists; + }; + return ( { aside={} onContextMenu={(event) => onNodeRightClick(event, rowData)} > - {rowData.content} + {rowData.content} ) : rowData.type === 'org' ? ( @@ -1061,7 +1191,24 @@ const InventoryTreeMenuLocal = () => { iconBefore={rowData.icon} onContextMenu={(event) => onNodeRightClick(event, rowData)} > - {rowData.content} + {!isUserLoggedIn || doesOrgNameExist(rowData.content) ? ( + {rowData.content} + ) : ( + + + {rowData.content} + + + )} ) : rowData.type === 'site' ? ( @@ -1075,7 +1222,25 @@ const InventoryTreeMenuLocal = () => { iconBefore={rowData.icon} onContextMenu={(event) => onNodeRightClick(event, rowData)} > - {rowData.content} + {!isUserLoggedIn || + doesSiteNameExist(rowData.parentValue.split('/').pop(), rowData.content) ? ( + {rowData.content} + ) : ( + + + {rowData.content} + + + )} ) : rowData.type === 'device' ? ( diff --git a/jccm/src/Frontend/MainEventProcessor.js b/jccm/src/Frontend/MainEventProcessor.js index e56f9b9..28d7bfd 100644 --- a/jccm/src/Frontend/MainEventProcessor.js +++ b/jccm/src/Frontend/MainEventProcessor.js @@ -16,7 +16,7 @@ export const MainEventProcessor = () => { const { isUserLoggedIn, setIsUserLoggedIn, user, setUser } = useStore(); const { inventory, setInventory } = useStore(); const { cloudInventory, setCloudInventory } = useStore(); - const { deviceFacts, setDeviceFactsAll, setDeviceFacts, deleteDeviceFacts, zeroDeviceFacts } = useStore(); + const { deviceFacts, setDeviceFactsAll, cleanUpDeviceFacts, zeroDeviceFacts } = useStore(); const { cloudInventoryFilterApplied, setCloudInventoryFilterApplied } = useStore(); const { currentActiveThemeName, setCurrentActiveThemeName } = useStore(); @@ -137,11 +137,13 @@ export const MainEventProcessor = () => { } } else { setUser(null); + setCloudInventory([]) setIsUserLoggedIn(false); setCurrentActiveThemeName(data.theme); } } catch (error) { setUser(null); + setCloudInventory([]) setIsUserLoggedIn(false); console.error('Session check error:', error); } @@ -158,11 +160,17 @@ export const MainEventProcessor = () => { } }; + const handleDeviceFactsCleanup = async () => { + console.log('handleDeviceFactsCleanup'); + cleanUpDeviceFacts(); + }; + eventBus.on('local-inventory-refresh', handleLocalInventoryRefresh); eventBus.on('cloud-inventory-refresh', handleCloudInventoryRefresh); eventBus.on('reset-device-facts', handleResetDeviceFacts); eventBus.on('user-session-check', handleUserSessionCheck); eventBus.on('device-facts-refresh', handleDeviceFactsRefresh); + eventBus.on('device-facts-cleanup', handleDeviceFactsCleanup); return () => { eventBus.off('local-inventory-refresh', handleLocalInventoryRefresh); @@ -170,6 +178,7 @@ export const MainEventProcessor = () => { eventBus.off('reset-device-facts', handleResetDeviceFacts); eventBus.off('user-session-check', handleUserSessionCheck); eventBus.off('device-facts-refresh', handleDeviceFactsRefresh); + eventBus.off('device-facts-cleanup', handleDeviceFactsCleanup); }; }, []); diff --git a/jccm/src/Services/ApiServer.js b/jccm/src/Services/ApiServer.js index 7e96582..42af25b 100644 --- a/jccm/src/Services/ApiServer.js +++ b/jccm/src/Services/ApiServer.js @@ -381,7 +381,8 @@ export const setupApiHandlers = () => { ipcMain.handle('saAdoptDevice', async (event, args) => { console.log('main: saAdoptDevice'); - const { organization, site, address, port, username, password, jsiTerm, ...others } = args; + const { organization, site, address, port, username, password, jsiTerm, deleteOutboundSSHTerm, ...others } = + args; const cloudOrgs = await msGetCloudOrgs(); const orgId = cloudOrgs[organization]?.id; @@ -393,10 +394,13 @@ export const setupApiHandlers = () => { endpoint = 'jsi/devices'; } - const api = `orgs/${orgId}/${endpoint}/outbound_ssh_cmd${siteId ? `?site_id=${siteId}` : ''}`; const response = await acRequest(api, 'GET', null); - const configCommand = `${response.cmd}\n`; + + const configCommand = deleteOutboundSSHTerm + ? `delete system services outbound-ssh\n${response.cmd}\n` + : `${response.cmd}\n`; + const reply = await commitJunosSetConfig(address, port, username, password, configCommand); if (reply.status === 'success' && reply.data.includes('')) {