diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..93c0e783b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Set the default behavior, in case people don't have core.autocrlf set. +* text eol=lf + +# Denote all files that are truly binary and should not be modified. +*.png binary +*.jpg binary \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 242a3ee9a..9ef48c933 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,4 @@ -* @mlabouardy @ShubhamPalriwala +* @mlabouardy @ShubhamPalriwala @AvineshTripathi @greghub @Traxmaxx +docs @jakepage91 README.md @jakepage91 CONTRIBUTING.md @jakepage91 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..53f93894f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/dashboard" + schedule: + interval: "weekly" + labels: + - "npm dependencies" + reviewers: + - "mlabouardy" + - "ShubhamPalriwala" + - "AvineshTripathi" diff --git a/.github/workflows/build_test_pr.yml b/.github/workflows/build_test_pr.yml index b00470cca..e17d73ae1 100644 --- a/.github/workflows/build_test_pr.yml +++ b/.github/workflows/build_test_pr.yml @@ -5,7 +5,7 @@ jobs: build_test_dashboard: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: @@ -28,14 +28,14 @@ jobs: build_test_cli: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v4 with: cache: true cache-dependency-path: go.sum - go-version: 1.20.2 + go-version: 1.21 # FIXME: https://github.com/golangci/golangci-lint-action/issues/677 # - name: golangci-lint diff --git a/README.md b/README.md index e4895ab66..3848e9fba 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@

+[![Build and Test komiser](https://github.com/tailwarden/komiser/actions/workflows/build_test.yml/badge.svg)](https://github.com/tailwarden/komiser/actions/workflows/build_test.yml) [![Price](https://img.shields.io/badge/price-FREE-0098f7.svg)](https://github.com/tailwarden/komiser/blob/master/LICENSE) [![Docker Stars](https://img.shields.io/docker/pulls/mlabouardy/komiser.svg)](https://hub.docker.com/r/mlabouardy/komiser) [![ELv2 License](https://img.shields.io/badge/license-ELv2-green)](LICENSE) [![Docker Stars](https://img.shields.io/github/issues/tailwarden/komiser.svg)](https://github.com/tailwarden/komiser/issues) [![Discord](https://badgen.net/badge/icon/discord?icon=discord&label)](https://discord.tailwarden.com/) @@ -162,16 +163,16 @@ We are very excited about what is in store in the coming weeks and months, take Komiser is written in `Golang` and is `Elv2 licensed` - contributions are always welcome whether that means providing feedback, be it through GitHub, through the `#feedback` channel on our [Discord server](https://discord.tailwarden.com) or testing existing and new features. Feel free to check out our [contributor guidelines](./CONTRIBUTING.md) and consider becoming a **contributor** today. -Learn how to contribute with this walkthrough videos: +Learn how to contribute with these walkthrough videos: -- [How to contributor to Komiser engine](https://www.youtube.com/watch?v=Vn5uc2elcVg) -- [How to contributor to Komiser dashboard](https://www.youtube.com/watch?v=uwxj11-eRt8) +- [How to contribute to Komiser engine](https://www.youtube.com/watch?v=Vn5uc2elcVg) +- [How to contribute to Komiser dashboard](https://www.youtube.com/watch?v=uwxj11-eRt8) # Users 🧑‍🤝‍🧑 If you'd like to have your company represented and are using `Komiser` please give formal written permission below via email to contact@tailwarden.com. -We will need a URL to a svg or png logo, a text title and a company URL. +We will need a URL to an SVG or png logo, a text title, and a company URL. # Versioning 🧮 diff --git a/dashboard/.eslintrc.json b/dashboard/.eslintrc.json index 4eb5b9478..f512edd6f 100644 --- a/dashboard/.eslintrc.json +++ b/dashboard/.eslintrc.json @@ -10,6 +10,14 @@ "jest/globals": true }, "plugins": ["jest", "prettier"], + "settings": { + "import/resolver": { + "node": { + "paths": ["src"], + "extensions": [".js", ".jsx", ".ts", ".tsx"] + } + } + }, "rules": { "react-hooks/exhaustive-deps": "off", "prettier/prettier": [ diff --git a/dashboard/.gitignore b/dashboard/.gitignore index ac1d86afd..c87c9b392 100644 --- a/dashboard/.gitignore +++ b/dashboard/.gitignore @@ -4,7 +4,6 @@ /node_modules /.pnp .pnp.js -/package-lock.json # testing /coverage diff --git a/dashboard/.tool-versions b/dashboard/.tool-versions new file mode 100644 index 000000000..70d0ba4ea --- /dev/null +++ b/dashboard/.tool-versions @@ -0,0 +1 @@ +nodejs 18.16.1 diff --git a/dashboard/components/account-details/AwsAccountDetails.tsx b/dashboard/components/account-details/AwsAccountDetails.tsx new file mode 100644 index 000000000..b64bcecc5 --- /dev/null +++ b/dashboard/components/account-details/AwsAccountDetails.tsx @@ -0,0 +1,192 @@ +import { ChangeEvent, ReactNode, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { AWSCredentials } from '@utils/cloudAccountHelpers'; +import Folder2Icon from '../icons/Folder2Icon'; +import SelectInput from '../onboarding-wizard/SelectInput'; +import LabelledInput from '../onboarding-wizard/LabelledInput'; +import InputFileSelect from '../onboarding-wizard/InputFileSelect'; +import KeyIcon from '../icons/KeyIcon'; +import VariableIcon from '../icons/VariableIcon'; +import DocumentTextIcon from '../icons/DocumentTextIcon'; +import ShieldSecurityIcon from '../icons/ShieldSecurityIcon'; +import { CloudAccountPayload } from '../cloud-account/hooks/useCloudAccounts/useCloudAccount'; + +interface SelectOptions { + icon: ReactNode; + label: string; + value: string; +} + +interface AwsAccountDetailsProps { + cloudAccountData?: CloudAccountPayload; + hasError?: boolean; +} + +const options: SelectOptions[] = [ + { + icon: , + label: 'Credentials File', + value: 'credentials-file' + }, + { + icon: , + label: 'Credentials keys', + value: 'credentials-keys' + }, + { + icon: , + label: 'Environment Variables', + value: 'environment-variables' + }, + { + icon: , + label: 'IAM Instance Role', + value: 'iam-instance-role' + } +]; + +function AwsAccountDetails({ + cloudAccountData, + hasError = false +}: AwsAccountDetailsProps) { + const [credentialType, setCredentialType] = useState( + options.find( + option => option.value === cloudAccountData?.credentials.source + )?.value ?? options[0].value + ); + const [isValidationError, setIsValidationError] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [file, setFile] = useState( + cloudAccountData?.credentials.path || '' + ); + + const fileInputRef = useRef(null); + const handleButtonClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + function handleSelectChange(newValue: string) { + setCredentialType(newValue); + } + + const handleFileChange = (event: ChangeEvent) => { + const fileName = event.target.files?.[0]?.name; + + if (fileName) { + setFile(fileName); + if (!fileName.endsWith('.db')) { + setIsValidationError(true); + setErrorMessage( + 'The chosen file is not supported. Please choose a different file for the credentials.' + ); + } + } else { + setIsValidationError(true); + setErrorMessage('Please choose a file.'); + } + }; + + return ( +
+ + +
+
+ option.value)} + /> + {[options[2].value, options[3].value].includes(credentialType) && ( +
+ {credentialType === options[3].value + ? 'Komiser will fetch the credentials from AWS' + : 'Komiser will load credentials from AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.'} +
+ )} +
+ + {credentialType === options[0].value && ( +
+ } + subLabel="Enter the path or browse the file" + placeholder="C:\Documents\Komiser\credentials" + fileInputRef={fileInputRef} + iconClick={handleButtonClick} + handleFileChange={handleFileChange} + handleInputChange={e => setFile(e.target.value)} + value={file} + hasError={isValidationError} + errorMessage={errorMessage} + /> + +
+ )} + + {credentialType === options[1].value && ( +
+ + +
+ )} +
+ {hasError && ( +
+ We couldn't connect to your AWS account. Please check if the file + is correct. +
+ )} +
+ ); +} + +export default AwsAccountDetails; diff --git a/dashboard/components/account-details/AzureAccountDetails.tsx b/dashboard/components/account-details/AzureAccountDetails.tsx new file mode 100644 index 000000000..19cfe0652 --- /dev/null +++ b/dashboard/components/account-details/AzureAccountDetails.tsx @@ -0,0 +1,127 @@ +import classNames from 'classnames'; +import { AzureCredentials } from '@utils/cloudAccountHelpers'; +import LabelledInput from '../onboarding-wizard/LabelledInput'; +import { CloudAccountPayload } from '../cloud-account/hooks/useCloudAccounts/useCloudAccount'; + +interface AzureAccountDetailsProps { + cloudAccountData?: CloudAccountPayload; + hasError?: boolean; +} + +function AzureAccountDetails({ + cloudAccountData, + hasError = false +}: AzureAccountDetailsProps) { + return ( +
+ + +
+ + + + + + } + /> + + + + +
+ {hasError && ( +
+ We couldn't connect to your Azure account. Please check if the + file is correct. +
+ )} +
+ ); +} + +export default AzureAccountDetails; diff --git a/dashboard/components/account-details/CivoAccountDetails.tsx b/dashboard/components/account-details/CivoAccountDetails.tsx new file mode 100644 index 000000000..1d8fb3a7e --- /dev/null +++ b/dashboard/components/account-details/CivoAccountDetails.tsx @@ -0,0 +1,62 @@ +import classNames from 'classnames'; +import RecordCircleIcon from '@components/icons/RecordCircleIcon'; +import { CivoCredentials } from '@utils/cloudAccountHelpers'; +import LabelledInput from '../onboarding-wizard/LabelledInput'; +import { CloudAccountPayload } from '../cloud-account/hooks/useCloudAccounts/useCloudAccount'; + +interface CivoAccountDetailsProps { + cloudAccountData?: CloudAccountPayload; + hasError?: boolean; +} + +function CivoAccountDetails({ + cloudAccountData, + hasError = false +}: CivoAccountDetailsProps) { + return ( +
+ + +
+ } + /> + +
+ {hasError && ( +
+ We couldn't connect to your Civo account. Please check if the + file is correct. +
+ )} +
+ ); +} + +export default CivoAccountDetails; diff --git a/dashboard/components/account-details/DigitalOceanAccountDetails.tsx b/dashboard/components/account-details/DigitalOceanAccountDetails.tsx new file mode 100644 index 000000000..4db9ad4b1 --- /dev/null +++ b/dashboard/components/account-details/DigitalOceanAccountDetails.tsx @@ -0,0 +1,61 @@ +import classNames from 'classnames'; +import RecordCircleIcon from '@components/icons/RecordCircleIcon'; +import { DigitalOceanCredentials } from '@utils/cloudAccountHelpers'; +import LabelledInput from '../onboarding-wizard/LabelledInput'; +import { CloudAccountPayload } from '../cloud-account/hooks/useCloudAccounts/useCloudAccount'; + +interface DigitalOceanAccountDetailsProps { + cloudAccountData?: CloudAccountPayload; + hasError?: boolean; +} + +function DigitalOceanAccountDetails({ + cloudAccountData, + hasError = false +}: DigitalOceanAccountDetailsProps) { + return ( +
+ + +
+ } + /> + +
+ {hasError && ( +
+ We couldn't connect to your Digital Ocean account. Please check + if the file is correct. +
+ )} +
+ ); +} + +export default DigitalOceanAccountDetails; diff --git a/dashboard/components/account-details/GcpAccountDetails.tsx b/dashboard/components/account-details/GcpAccountDetails.tsx new file mode 100644 index 000000000..366a64180 --- /dev/null +++ b/dashboard/components/account-details/GcpAccountDetails.tsx @@ -0,0 +1,95 @@ +import { ChangeEvent, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { GCPCredentials } from '@utils/cloudAccountHelpers'; +import Folder2Icon from '../icons/Folder2Icon'; +import LabelledInput from '../onboarding-wizard/LabelledInput'; +import InputFileSelect from '../onboarding-wizard/InputFileSelect'; +import { CloudAccountPayload } from '../cloud-account/hooks/useCloudAccounts/useCloudAccount'; + +interface GcpAccountDetailsProps { + cloudAccountData?: CloudAccountPayload; + hasError?: boolean; +} + +function GcpAccountDetails({ + cloudAccountData, + hasError = false +}: GcpAccountDetailsProps) { + const [isValidationError, setIsValidationError] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [file, setFile] = useState( + cloudAccountData?.credentials.serviceAccountKeyPath || '' + ); + + const fileInputRef = useRef(null); + const handleButtonClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + const handleFileChange = (event: ChangeEvent) => { + const fileName = event.target.files?.[0]?.name; + + if (fileName) { + if (!fileName.endsWith('.json')) { + setIsValidationError(true); + setErrorMessage( + 'The chosen file is not supported. Please choose a different file for the credentials.' + ); + setFile(fileName); + } + } else { + setIsValidationError(true); + setErrorMessage('Please choose a file.'); + } + }; + + return ( +
+ + +
+
+ } + subLabel="Enter the path or browse the file" + placeholder="C:\Documents\Komiser\credentials" + fileInputRef={fileInputRef} + iconClick={handleButtonClick} + handleFileChange={handleFileChange} + handleInputChange={e => setFile(e.target.value)} + value={file} + hasError={isValidationError} + errorMessage={errorMessage} + /> +
+
+ {hasError && ( +
+ We couldn't connect to your GCP account. Please check if the file + is correct. +
+ )} +
+ ); +} + +export default GcpAccountDetails; diff --git a/dashboard/components/account-details/KubernetesAccountDetails.tsx b/dashboard/components/account-details/KubernetesAccountDetails.tsx new file mode 100644 index 000000000..b906a66cd --- /dev/null +++ b/dashboard/components/account-details/KubernetesAccountDetails.tsx @@ -0,0 +1,125 @@ +import { ReactNode, useRef, useState, ChangeEvent } from 'react'; +import classNames from 'classnames'; +import DocumentTextIcon from '@components/icons/DocumentTextIcon'; +import SelectInput from '@components/onboarding-wizard/SelectInput'; +import InputFileSelect from '@components/onboarding-wizard/InputFileSelect'; +import Folder2Icon from '@components/icons/Folder2Icon'; +import { KubernetesCredentials } from '@utils/cloudAccountHelpers'; +import LabelledInput from '../onboarding-wizard/LabelledInput'; +import { CloudAccountPayload } from '../cloud-account/hooks/useCloudAccounts/useCloudAccount'; + +interface KubernetesAccountDetailsProps { + cloudAccountData?: CloudAccountPayload; + hasError?: boolean; +} + +interface SelectOptions { + icon: ReactNode; + label: string; + value: string; +} + +const options: SelectOptions[] = [ + { + icon: , + label: 'Credentials File', + value: 'credentials-file' + } +]; + +function KubernetesAccountDetails({ + cloudAccountData, + hasError = false +}: KubernetesAccountDetailsProps) { + const [credentialType, setCredentialType] = useState( + options.find( + option => option.value === cloudAccountData?.credentials.source + )?.value ?? options[0].value + ); + const [isValidationError, setIsValidationError] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [file, setFile] = useState( + cloudAccountData?.credentials.file || '' + ); + + const fileInputRef = useRef(null); + const handleButtonClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + function handleSelectChange(newValue: string) { + setCredentialType(newValue); + } + + const handleFileChange = (event: ChangeEvent) => { + const fileName = event.target.files?.[0]?.name; + + if (fileName) { + if (!fileName.endsWith('.db')) { + setIsValidationError(true); + setErrorMessage( + 'The chosen file is not supported. Please choose a different file for the credentials.' + ); + setFile(fileName); + } + } else { + setIsValidationError(true); + setErrorMessage('Please choose a file.'); + } + }; + + return ( +
+ +
+ option.value)} + /> + } + fileInputRef={fileInputRef} + iconClick={handleButtonClick} + handleFileChange={handleFileChange} + handleInputChange={e => setFile(e.target.value)} + hasError={isValidationError} + errorMessage={errorMessage} + /> +
+ {hasError && ( +
+ We couldn't connect to your Kubernetes account. Please check if + the file is correct. +
+ )} +
+ ); +} + +export default KubernetesAccountDetails; diff --git a/dashboard/components/account-details/LinodeAccountDetails.tsx b/dashboard/components/account-details/LinodeAccountDetails.tsx new file mode 100644 index 000000000..200c9e5ab --- /dev/null +++ b/dashboard/components/account-details/LinodeAccountDetails.tsx @@ -0,0 +1,52 @@ +import classNames from 'classnames'; +import { LinodeCredentials } from '@utils/cloudAccountHelpers'; +import LabelledInput from '../onboarding-wizard/LabelledInput'; +import { CloudAccountPayload } from '../cloud-account/hooks/useCloudAccounts/useCloudAccount'; + +interface LinodeAccountDetailsProps { + cloudAccountData?: CloudAccountPayload; + hasError?: boolean; +} + +function LinodeAccountDetails({ + cloudAccountData, + hasError = false +}: LinodeAccountDetailsProps) { + return ( +
+ + +
+ +
+ {hasError && ( +
+ We couldn't connect to your Linode account. Please check if the + file is correct. +
+ )} +
+ ); +} + +export default LinodeAccountDetails; diff --git a/dashboard/components/account-details/MongoDBAtlasAccountDetails.tsx b/dashboard/components/account-details/MongoDBAtlasAccountDetails.tsx new file mode 100644 index 000000000..31b6dbfbf --- /dev/null +++ b/dashboard/components/account-details/MongoDBAtlasAccountDetails.tsx @@ -0,0 +1,67 @@ +import classNames from 'classnames'; +import { MongoDBAtlasCredentials } from '@utils/cloudAccountHelpers'; +import LabelledInput from '../onboarding-wizard/LabelledInput'; +import { CloudAccountPayload } from '../cloud-account/hooks/useCloudAccounts/useCloudAccount'; + +interface MongoDbAtlasAccountDetailsProps { + cloudAccountData?: CloudAccountPayload; + hasError?: boolean; +} + +function MongoDbAtlasAccountDetails({ + cloudAccountData, + hasError = false +}: MongoDbAtlasAccountDetailsProps) { + return ( +
+ + +
+ + + +
+ {hasError && ( +
+ We couldn't connect to your MongoDB Atlas account. Please check + if the file is correct. +
+ )} +
+ ); +} + +export default MongoDbAtlasAccountDetails; diff --git a/dashboard/components/account-details/OciAccountDetails.tsx b/dashboard/components/account-details/OciAccountDetails.tsx new file mode 100644 index 000000000..1cd052786 --- /dev/null +++ b/dashboard/components/account-details/OciAccountDetails.tsx @@ -0,0 +1,95 @@ +import { ChangeEvent, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { OCICredentials } from '@utils/cloudAccountHelpers'; +import Folder2Icon from '../icons/Folder2Icon'; +import LabelledInput from '../onboarding-wizard/LabelledInput'; +import InputFileSelect from '../onboarding-wizard/InputFileSelect'; +import { CloudAccountPayload } from '../cloud-account/hooks/useCloudAccounts/useCloudAccount'; + +interface OciAccountDetailsProps { + cloudAccountData?: CloudAccountPayload; + hasError?: boolean; +} + +function OciAccountDetails({ + cloudAccountData, + hasError = false +}: OciAccountDetailsProps) { + const [isValidationError, setIsValidationError] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [file, setFile] = useState( + cloudAccountData?.credentials.path || '' + ); + + const fileInputRef = useRef(null); + const handleButtonClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + const handleFileChange = (event: ChangeEvent) => { + const fileName = event.target.files?.[0]?.name; + + if (fileName) { + if (!fileName.endsWith('.db')) { + setIsValidationError(true); + setErrorMessage( + 'The chosen file is not supported. Please choose a different file for the credentials.' + ); + setFile(fileName); + } + } else { + setIsValidationError(true); + setErrorMessage('Please choose a file.'); + } + }; + + return ( +
+ + +
+
+ } + subLabel="Enter the path or browse the file" + placeholder="C:\Documents\Komiser\credentials" + fileInputRef={fileInputRef} + iconClick={handleButtonClick} + handleFileChange={handleFileChange} + handleInputChange={e => setFile(e.target.value)} + value={file} + hasError={isValidationError} + errorMessage={errorMessage} + /> +
+
+ {hasError && ( +
+ We couldn't connect to your OCI account. Please check if the file + is correct. +
+ )} +
+ ); +} + +export default OciAccountDetails; diff --git a/dashboard/components/account-details/ScalewayAccountDetails.tsx b/dashboard/components/account-details/ScalewayAccountDetails.tsx new file mode 100644 index 000000000..3152a2b69 --- /dev/null +++ b/dashboard/components/account-details/ScalewayAccountDetails.tsx @@ -0,0 +1,67 @@ +import classNames from 'classnames'; +import { ScalewayCredentials } from '@utils/cloudAccountHelpers'; +import LabelledInput from '../onboarding-wizard/LabelledInput'; +import { CloudAccountPayload } from '../cloud-account/hooks/useCloudAccounts/useCloudAccount'; + +interface ScalewayAccountDetailsProps { + cloudAccountData?: CloudAccountPayload; + hasError?: boolean; +} + +function ScalewayAccountDetails({ + cloudAccountData, + hasError = false +}: ScalewayAccountDetailsProps) { + return ( +
+ + +
+ + + +
+ {hasError && ( +
+ We couldn't connect to your Scaleway account. Please check if the + file is correct. +
+ )} +
+ ); +} + +export default ScalewayAccountDetails; diff --git a/dashboard/components/account-details/TencentAccountDetails.tsx b/dashboard/components/account-details/TencentAccountDetails.tsx new file mode 100644 index 000000000..e3a3cd513 --- /dev/null +++ b/dashboard/components/account-details/TencentAccountDetails.tsx @@ -0,0 +1,52 @@ +import classNames from 'classnames'; +import { TencentCredentials } from '@utils/cloudAccountHelpers'; +import LabelledInput from '../onboarding-wizard/LabelledInput'; +import { CloudAccountPayload } from '../cloud-account/hooks/useCloudAccounts/useCloudAccount'; + +interface TencentAccountDetailsProps { + cloudAccountData?: CloudAccountPayload; + hasError?: boolean; +} + +function TencentAccountDetails({ + cloudAccountData, + hasError = false +}: TencentAccountDetailsProps) { + return ( +
+ + +
+ +
+ {hasError && ( +
+ We couldn't connect to your Tencent account. Please check if the + file is correct. +
+ )} +
+ ); +} + +export default TencentAccountDetails; diff --git a/dashboard/components/cloud-account/components/CloudAccountDeleteContents.tsx b/dashboard/components/cloud-account/components/CloudAccountDeleteContents.tsx new file mode 100644 index 000000000..69398b196 --- /dev/null +++ b/dashboard/components/cloud-account/components/CloudAccountDeleteContents.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import AlertCircleIcon from '../../icons/AlertCircleIcon'; +import Button from '../../button/Button'; +import { CloudAccount } from '../hooks/useCloudAccounts/useCloudAccount'; +import settingsService from '../../../services/settingsService'; +import { ToastProps } from '../../toast/hooks/useToast'; + +interface CloudAccountDeleteContentsProps { + cloudAccount: CloudAccount; + onCancel: () => void; + handleAfterDelete: (account: CloudAccount) => void; + setToast: (toast: ToastProps) => void; +} + +function CloudAccountDeleteContents({ + cloudAccount, + onCancel, + handleAfterDelete, + setToast +}: CloudAccountDeleteContentsProps) { + const [loading, setLoading] = useState(false); + + const deleteCloudAccount = () => { + if (!cloudAccount.id) return false; + + setLoading(true); + + settingsService.deleteCloudAccount(cloudAccount.id).then(res => { + setLoading(false); + if (res === Error) { + setToast({ + hasError: true, + title: 'Cloud account was not deleted', + message: + 'There was an error deleting this cloud account. Please try again.' + }); + } else { + setToast({ + hasError: false, + title: 'Cloud account deleted', + message: `The cloud account was successfully deleted!` + }); + handleAfterDelete(cloudAccount); + } + }); + + return true; + }; + + return ( + <> +
+ +

+ Are you sure you want to +
+ remove this cloud account? +

+

+ All related data (like custom views and tags) will be deleted +
+ and the {cloudAccount.name} account will be disconnected from Komiser. +

+
+
+ + +
+ + ); +} + +export default CloudAccountDeleteContents; diff --git a/dashboard/components/cloud-account/components/CloudAccountItem.tsx b/dashboard/components/cloud-account/components/CloudAccountItem.tsx new file mode 100644 index 000000000..82a0a3473 --- /dev/null +++ b/dashboard/components/cloud-account/components/CloudAccountItem.tsx @@ -0,0 +1,101 @@ +import { useState, useEffect, useRef } from 'react'; +import Image from 'next/image'; +import providers from '@utils/providerHelper'; +import { CloudAccount } from '../hooks/useCloudAccounts/useCloudAccount'; +import CloudAccountStatus from './CloudAccountStatus'; +import More2Icon from '../../icons/More2Icon'; +import DeleteIcon from '../../icons/DeleteIcon'; +import EditIcon from '../../icons/EditIcon'; + +export default function CloudAccountItem({ + account, + openModal, + setEditCloudAccount, + setIsDeleteModalOpen, + setCloudAccountItem +}: { + account: CloudAccount; + openModal: (cloudAccount: CloudAccount) => void; + setEditCloudAccount: (editCloudAccount: boolean) => void; + setIsDeleteModalOpen: (isDeleteModalOpen: boolean) => void; + setCloudAccountItem: (cloudAccountItem: CloudAccount) => void; +}) { + const optionsRef = useRef(null); + const { id, provider, name, status } = account; + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + const handleOutsideClick = (event: MouseEvent) => { + if ( + optionsRef.current && + !optionsRef.current.contains(event.target as Node) + ) { + setIsOpen(false); // Close the options if clicked outside + } + }; + + document.addEventListener('mousedown', handleOutsideClick); + + return () => { + document.removeEventListener('mousedown', handleOutsideClick); + }; + }, []); + + return ( +
openModal(account)} + className="relative my-5 flex w-full items-center gap-4 rounded-lg border-2 border-black-170 bg-white p-6 text-black-900 transition-colors" + > + {`${name} + +
+

{name}

+

{providers.providerLabel(provider)}

+
+ + + + setIsOpen(!isOpen)} + /> + + {isOpen && ( +
+ + +
+ )} +
+ ); +} diff --git a/dashboard/components/cloud-account/components/CloudAccountStatus.tsx b/dashboard/components/cloud-account/components/CloudAccountStatus.tsx new file mode 100644 index 000000000..9cd078a5f --- /dev/null +++ b/dashboard/components/cloud-account/components/CloudAccountStatus.tsx @@ -0,0 +1,23 @@ +import classNames from 'classnames'; +import { CloudAccount } from '../hooks/useCloudAccounts/useCloudAccount'; + +function CloudAccountStatus({ status }: { status: CloudAccount['status'] }) { + if (!status) return null; + + return ( +
+ {status.charAt(0) + status.slice(1).toLocaleLowerCase()} +
+ ); +} + +export default CloudAccountStatus; diff --git a/dashboard/components/cloud-account/components/CloudAccountsHeader.tsx b/dashboard/components/cloud-account/components/CloudAccountsHeader.tsx new file mode 100644 index 000000000..dd62c5f38 --- /dev/null +++ b/dashboard/components/cloud-account/components/CloudAccountsHeader.tsx @@ -0,0 +1,22 @@ +import Button from '../../button/Button'; +import PlusIcon from '../../icons/PlusIcon'; + +type CloudAccountsHeaderProps = { + isNotCustomView: boolean; +}; + +function CloudAccountsHeader({ isNotCustomView }: CloudAccountsHeaderProps) { + return ( +
+ {isNotCustomView && ( + <> +

+ Your Cloud Accounts +

+ + )} +
+ ); +} + +export default CloudAccountsHeader; diff --git a/dashboard/components/cloud-account/components/CloudAccountsLayout.tsx b/dashboard/components/cloud-account/components/CloudAccountsLayout.tsx new file mode 100644 index 000000000..ae617fb4d --- /dev/null +++ b/dashboard/components/cloud-account/components/CloudAccountsLayout.tsx @@ -0,0 +1,90 @@ +import { NextRouter } from 'next/router'; +import { ReactNode, useContext } from 'react'; + +import GlobalAppContext from '../../layout/context/GlobalAppContext'; +import Providers, { allProviders } from '../../../utils/providerHelper'; +import { CloudAccount } from '../hooks/useCloudAccounts/useCloudAccount'; + +type CloudAccountsLayoutProps = { + cloudAccounts: CloudAccount[]; + children: ReactNode; + router: NextRouter; +}; + +function CloudAccountsLayout({ + cloudAccounts, + children, + router +}: CloudAccountsLayoutProps) { + const { displayBanner } = useContext(GlobalAppContext); + + const cloudProviders = Object.values(allProviders); + + return ( + <> + +
{children}
+ + ); +} + +export default CloudAccountsLayout; diff --git a/dashboard/components/cloud-account/components/CloudAccountsSidePanel.tsx b/dashboard/components/cloud-account/components/CloudAccountsSidePanel.tsx new file mode 100644 index 000000000..a9dcb1d6e --- /dev/null +++ b/dashboard/components/cloud-account/components/CloudAccountsSidePanel.tsx @@ -0,0 +1,236 @@ +import { FormEvent, useState } from 'react'; +import AzureAccountDetails from '@components/account-details/AzureAccountDetails'; +import GcpAccountDetails from '@components/account-details/GcpAccountDetails'; +import DigitalOceanAccountDetails from '@components/account-details/DigitalOceanAccountDetails'; +import CivoAccountDetails from '@components/account-details/CivoAccountDetails'; +import LinodeAccountDetails from '@components/account-details/LinodeAccountDetails'; +import KubernetesAccountDetails from '@components/account-details/KubernetesAccountDetails'; +import TencentAccountDetails from '@components/account-details/TencentAccountDetails'; +import MongoDbAtlasAccountDetails from '@components/account-details/MongoDBAtlasAccountDetails'; +import OciAccountDetails from '@components/account-details/OciAccountDetails'; +import ScalewayAccountDetails from '@components/account-details/ScalewayAccountDetails'; +import { getPayloadFromForm } from '@utils/cloudAccountHelpers'; +import providers, { + allProviders, + Provider +} from '../../../utils/providerHelper'; +import AwsAccountDetails from '../../account-details/AwsAccountDetails'; +import Button from '../../button/Button'; +import Sidepanel from '../../sidepanel/Sidepanel'; +import SidepanelTabs from '../../sidepanel/SidepanelTabs'; +import CloudAccountStatus from './CloudAccountStatus'; +import CloudAccountDeleteContents from './CloudAccountDeleteContents'; +import { + CloudAccount, + CloudAccountsPage +} from '../hooks/useCloudAccounts/useCloudAccount'; +import { ToastProps } from '../../toast/hooks/useToast'; +import settingsService from '../../../services/settingsService'; + +interface CloudAccountsSidePanelProps { + isOpen: boolean; + closeModal: () => void; + cloudAccount: CloudAccount; + cloudAccounts: CloudAccount[]; + setCloudAccounts: (cloudAccounts: CloudAccount[]) => void; + handleAfterDelete: (account: CloudAccount) => void; + page: CloudAccountsPage; + goTo: (page: CloudAccountsPage) => void; + setToast: (toast: ToastProps) => void; +} + +function AccountDetails({ + cloudAccountData +}: { + cloudAccountData: CloudAccount; +}) { + switch (cloudAccountData.provider.toLocaleLowerCase()) { + case allProviders.AWS: + return ; + case allProviders.GCP: + return ; + case allProviders.DIGITAL_OCEAN: + return ; + case allProviders.AZURE: + return ; + case allProviders.CIVO: + return ; + case allProviders.KUBERNETES: + return ; + case allProviders.LINODE: + return ; + case allProviders.TENCENT: + return ; + case allProviders.OCI: + return ; + case allProviders.SCALE_WAY: + return ; + case allProviders.MONGODB_ATLAS: + return ; + default: + return null; + } +} + +function CloudAccountsSidePanel({ + isOpen, + closeModal, + cloudAccount, + cloudAccounts, + setCloudAccounts, + handleAfterDelete, + page, + goTo, + setToast +}: CloudAccountsSidePanelProps) { + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [loading, setLoading] = useState(false); + + const handleEditCloudAccount = ( + event: FormEvent, + id: number | undefined, + provider: Provider + ) => { + event.preventDefault(); + if (!id) return false; + + setLoading(true); + const payloadJson = JSON.stringify( + getPayloadFromForm(new FormData(event.currentTarget), provider) + ); + settingsService.editCloudAccount(id, payloadJson).then(res => { + if (res === Error || res.error) { + setLoading(false); + setToast({ + hasError: true, + title: 'Cloud account not edited', + message: + 'There was an error editing this cloud account. Refer to the logs and try again.' + }); + } else { + setLoading(false); + setToast({ + hasError: false, + title: 'Cloud account edited', + message: `The cloud account was successfully edited!` + }); + setCloudAccounts( + cloudAccounts.map(c => (c.id === cloudAccount.id ? res : c)) + ); + closeModal(); + } + }); + + return true; + }; + + return ( + <> + +
+ {/* Modal headers */} +
+ {cloudAccount && ( +
+ + {cloudAccount.provider} + + +
+
+

+ {cloudAccount.name} +

+
+

+ {cloudAccount.resources} resources in this cloud account +

+
+
+ )} + +
+ {!isDeleteOpen && ( + + )} + + +
+
+ + {isDeleteOpen ? ( +
+ setIsDeleteOpen(false)} + handleAfterDelete={(account: CloudAccount) => { + handleAfterDelete(account); + setIsDeleteOpen(false); + closeModal(); + }} + setToast={setToast} + /> +
+ ) : ( + <> + {/* Tabs */} + + + {/* Cloud account details */} + + {page === 'cloud account details' && ( +
+ handleEditCloudAccount( + event, + cloudAccount.id, + cloudAccount.provider + ) + } + > + +
+ + + +
+
+
+ + +
+
+ )} + + )} +
+
+ + ); +} + +export default CloudAccountsSidePanel; diff --git a/dashboard/components/cloud-account/hooks/useCloudAccounts/useCloudAccount.ts b/dashboard/components/cloud-account/hooks/useCloudAccounts/useCloudAccount.ts new file mode 100644 index 000000000..0bf63e15b --- /dev/null +++ b/dashboard/components/cloud-account/hooks/useCloudAccounts/useCloudAccount.ts @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; + +import { Credentials } from '@utils/cloudAccountHelpers'; + +import { Provider } from '../../../../utils/providerHelper'; +import settingsService from '../../../../services/settingsService'; + +export interface CloudAccount { + credentials: Credentials; + id?: number; + name: string; + provider: Provider; + resources?: number; + status?: 'CONNECTED' | 'INTEGRATION_ISSUE' | 'PERMISSION_ISSUE'; +} + +export interface CloudAccountPayload { + name: string; + provider: Provider; + credentials: T; +} + +export type CloudAccountsPage = 'cloud account details'; + +function useCloudAccount() { + const router = useRouter(); + const [page, setPage] = useState('cloud account details'); + const [cloudAccounts, setCloudAccounts] = useState([]); + const [cloudAccountItem, setCloudAccountItem] = useState(); + const isNotCustomView = !router.query.view; + const [isLoading, setIsLoading] = useState(true); + const [hasError, setHasError] = useState(false); + + useEffect(() => { + if (!isLoading) { + setIsLoading(true); + } + + settingsService.getCloudAccounts().then(res => { + if (res === Error) { + setHasError(true); + } else { + setCloudAccounts(res); + } + + setIsLoading(false); + }); + }, []); + + function openModal(CloudAccountItem: CloudAccount) { + setCloudAccountItem(CloudAccountItem); + } + + /** Handles the page change inside the modal */ + function goTo(newPage: CloudAccountsPage) { + setPage(newPage); + } + + return { + openModal, + page, + cloudAccountItem, + setCloudAccountItem, + goTo, + hasError, + cloudAccounts, + setCloudAccounts, + isNotCustomView, + isLoading + }; +} + +export default useCloudAccount; diff --git a/dashboard/components/dashboard/components/cost-explorer/hooks/useCostExplorer.tsx b/dashboard/components/dashboard/components/cost-explorer/hooks/useCostExplorer.tsx index 43deb402c..7b8eba919 100644 --- a/dashboard/components/dashboard/components/cost-explorer/hooks/useCostExplorer.tsx +++ b/dashboard/components/dashboard/components/cost-explorer/hooks/useCostExplorer.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import settingsService from '../../../../../services/settingsService'; import dateHelper, { + thisMonth, lastMonth, lastSixMonths, lastThreeMonths, @@ -23,6 +24,7 @@ export type CostExplorerQueryGroupProps = | 'view'; export type CostExplorerQueryGranularityProps = 'monthly' | 'daily'; export type CostExplorerQueryDateProps = + | 'thisMonth' | 'lastMonth' | 'lastThreeMonths' | 'lastSixMonths' @@ -58,6 +60,9 @@ function useCostExplorer() { let startDate = ''; let endDate = ''; + if (queryDate === 'thisMonth') { + [startDate, endDate] = thisMonth; + } if (queryDate === 'lastMonth') { [startDate, endDate] = lastMonth; } diff --git a/dashboard/components/dashboard/components/cost-explorer/hooks/useCostExplorerChart.tsx b/dashboard/components/dashboard/components/cost-explorer/hooks/useCostExplorerChart.tsx index 9ced0b164..5baff1201 100644 --- a/dashboard/components/dashboard/components/cost-explorer/hooks/useCostExplorerChart.tsx +++ b/dashboard/components/dashboard/components/cost-explorer/hooks/useCostExplorerChart.tsx @@ -100,12 +100,14 @@ function useCostExplorerChart({ const dateSelect = { values: [ + 'thisMonth', 'lastMonth', 'lastThreeMonths', 'lastSixMonths', 'lastTwelveMonths' ], displayValues: [ + 'This month', 'Last month', 'Last 3 months', 'Last 6 months', diff --git a/dashboard/components/dashboard/components/cost-explorer/utils/dateHelper.ts b/dashboard/components/dashboard/components/cost-explorer/utils/dateHelper.ts index 178dbfc4e..d02cb56c8 100644 --- a/dashboard/components/dashboard/components/cost-explorer/utils/dateHelper.ts +++ b/dashboard/components/dashboard/components/cost-explorer/utils/dateHelper.ts @@ -4,6 +4,12 @@ const dateHelper = { return date.toJSON().slice(0, 10); }, + getFirstDayOfThisMonth() { + const date = new Date(); + date.setDate(1); + return date.toJSON().slice(0, 10); + }, + getLastMonth() { const date = new Date(); date.setDate(1); @@ -71,7 +77,10 @@ export function dateFormatter(dateParam: string, granularity: string) { } return formattedDate; } - +export const thisMonth = [ + dateHelper.getFirstDayOfThisMonth(), + dateHelper.getToday() +]; export const lastMonth = [ dateHelper.getLastMonth(), dateHelper.getLastDayOfLastMonth() @@ -82,7 +91,7 @@ export const lastThreeMonths = [ ]; export const lastSixMonths = [ dateHelper.getLastSixMonths(), - dateHelper.getToday() + dateHelper.getLastDayOfLastMonth() ]; export const lastTwelveMonths = [ dateHelper.getLastTwelveMonths(), diff --git a/dashboard/components/explorer/DependencyGraph.tsx b/dashboard/components/explorer/DependencyGraph.tsx new file mode 100644 index 000000000..0b116759f --- /dev/null +++ b/dashboard/components/explorer/DependencyGraph.tsx @@ -0,0 +1,163 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import React, { useState, memo } from 'react'; +import CytoscapeComponent from 'react-cytoscapejs'; +import Cytoscape, { EventObject } from 'cytoscape'; +import popper from 'cytoscape-popper'; + +import nodeHtmlLabel, { + CytoscapeNodeHtmlParams + // @ts-ignore +} from 'cytoscape-node-html-label'; + +// @ts-ignore +import COSEBilkent from 'cytoscape-cose-bilkent'; + +import Tooltip from '@components/tooltip/Tooltip'; +import WarningIcon from '@components/icons/WarningIcon'; +import { ReactFlowData } from './hooks/useDependencyGraph'; +import { + edgeAnimationConfig, + edgeStyleConfig, + graphLayoutConfig, + leafStyleConfig, + maxZoom, + minZoom, + nodeHTMLLabelConfig, + nodeStyeConfig, + zoomLevelBreakpoint +} from './config'; + +export type DependencyGraphProps = { + data: ReactFlowData; +}; + +nodeHtmlLabel(Cytoscape.use(COSEBilkent)); +Cytoscape.use(popper); +const DependencyGraph = ({ data }: DependencyGraphProps) => { + const [initDone, setInitDone] = useState(false); + + // Type technically is Cytoscape.EdgeCollection but that throws an unexpected error + const loopAnimation = (eles: any) => { + const ani = eles.animation(edgeAnimationConfig[0], edgeAnimationConfig[1]); + + ani + .reverse() + .play() + .promise('complete') + .then(() => loopAnimation(eles)); + }; + + const cyActionHandlers = (cy: Cytoscape.Core) => { + // make sure we did not init already, otherwise this will be bound more than once + if (!initDone) { + // Add HTML labels for better flexibility + // @ts-ignore + cy.nodeHtmlLabel([ + { + ...nodeHTMLLabelConfig, + tpl(templateData: Cytoscape.NodeDataDefinition) { + return `

${templateData.label || ' '}

+

${templateData.service || ' '}

`; + } + } + ]); + // Add class to leave nodes so we can make them smaller + cy.nodes().leaves().addClass('leaf'); + // same for root notes + cy.nodes().roots().addClass('root'); + // Animate edges + cy.edges().forEach(loopAnimation); + + // Add hover tooltip on edges + cy.edges().bind('mouseover', event => { + if (cy.zoom() >= zoomLevelBreakpoint) { + // eslint-disable-next-line no-param-reassign + event.target.popperRefObj = event.target.popper({ + content: () => { + const content = document.createElement('div'); + content.classList.add('popper-div'); + content.innerHTML = event.target.data('label'); + + document.body.appendChild(content); + return content; + } + }); + } + }); + // Hide Edges tooltip on mouseout + cy.edges().bind('mouseout', event => { + if (cy.zoom() >= zoomLevelBreakpoint && event.target.popperRefObj) { + event.target.popperRefObj.state.elements.popper.remove(); + event.target.popperRefObj.destroy(); + } + }); + + // Hide labels when being zoomed out + cy.on('zoom', event => { + const opacity = cy.zoom() <= zoomLevelBreakpoint ? 0 : 1; + + Array.from( + document.querySelectorAll('.dependency-graph-node-label'), + e => { + // @ts-ignore + e.style.opacity = opacity; + return e; + } + ); + }); + // Make sure to tell we inited successfully and prevent another init + setInitDone(true); + } + }; + + return ( +
+ cyActionHandlers(cy)} + /> +
+ {data?.nodes?.length} Resources +
+ + + Only AWS resources are currently supported on the explorer. + +
+
+
+ ); +}; + +export default memo(DependencyGraph); diff --git a/dashboard/components/explorer/DependencyGraphError.tsx b/dashboard/components/explorer/DependencyGraphError.tsx new file mode 100644 index 000000000..f996e2ec4 --- /dev/null +++ b/dashboard/components/explorer/DependencyGraphError.tsx @@ -0,0 +1,67 @@ +import Button from '@components/button/Button'; + +type DashboardDependencyGraphErrorProps = { + fetch: () => void; +}; + +function DependencyGraphError({ fetch }: DashboardDependencyGraphErrorProps) { + return ( + <> +
+
+
+

+ Dependency Graph +

+
+

+ Analyze account resource associations +

+
+
+
+
+
+ + + + +

+ Cannot fetch Relationships +

+
+ +
+
+
+
+ + ); +} + +export default DependencyGraphError; diff --git a/dashboard/components/explorer/DependencyGraphLoader.tsx b/dashboard/components/explorer/DependencyGraphLoader.tsx new file mode 100644 index 000000000..1e64707e7 --- /dev/null +++ b/dashboard/components/explorer/DependencyGraphLoader.tsx @@ -0,0 +1,29 @@ +import { memo } from 'react'; +import DependencyGraphError from './DependencyGraphError'; +import DependencyGraphSkeleton from './DependencyGraphSkeleton'; +import DependencyGraphView from './DependencyGraph'; +import { ReactFlowData } from './hooks/useDependencyGraph'; + +export type DependencyGraphLoaderProps = { + loading: boolean; + data: ReactFlowData | undefined; + error: boolean; + fetch: () => void; +}; + +function DependencyGraphLoader({ + loading, + data, + error, + fetch +}: DependencyGraphLoaderProps) { + if (loading) return ; + + if (error) return ; + + if (data && !loading) return ; + + return null; +} + +export default memo(DependencyGraphLoader); diff --git a/dashboard/components/explorer/DependencyGraphSkeleton.tsx b/dashboard/components/explorer/DependencyGraphSkeleton.tsx new file mode 100644 index 000000000..34af17184 --- /dev/null +++ b/dashboard/components/explorer/DependencyGraphSkeleton.tsx @@ -0,0 +1,18 @@ +function DependencyGraphSkeleton() { + return ( + <> +
+
+
+
+
+
+
+ + ); +} + +export default DependencyGraphSkeleton; diff --git a/dashboard/components/explorer/DependencyGraphWrapper.tsx b/dashboard/components/explorer/DependencyGraphWrapper.tsx new file mode 100644 index 000000000..fb9e6cecc --- /dev/null +++ b/dashboard/components/explorer/DependencyGraphWrapper.tsx @@ -0,0 +1,109 @@ +import { useRouter } from 'next/router'; +import cn from 'classnames'; + +import { useEffect, useState } from 'react'; +import parseURLParams from '@components/inventory/hooks/useInventory/helpers/parseURLParams'; +import { InventoryFilterData } from '@components/inventory/hooks/useInventory/types/useInventoryTypes'; +import ArrowDownIcon from '@components/icons/ArrowDownIcon'; +import DependencyGraphLoader from './DependencyGraphLoader'; +import DependendencyGraphFilter from './filter/DependendencyGraphFilter'; +import useDependencyGraph from './hooks/useDependencyGraph'; + +function DependencyGraphWrapper() { + const { + loading, + data, + error, + fetch, + filters, + displayedFilters, + setDisplayedFilters, + deleteFilter, + setFilters + } = useDependencyGraph(); + const router = useRouter(); + const [filterOpen, setFilterOpen] = useState(false); + + useEffect(() => { + const newFilters: InventoryFilterData[] = Object.keys(router.query).map( + param => parseURLParams(param as string, 'fetch') + ); + const newFiltersToDisplay: InventoryFilterData[] = Object.keys( + router.query + ).map(param => parseURLParams(param as string, 'display')); + + setFilters(newFilters); + setDisplayedFilters(newFiltersToDisplay); + }, [router.query]); + + useEffect(() => { + const newFilters: InventoryFilterData[] = Object.keys(router.query).map( + param => parseURLParams(param as string, 'fetch') + ); + const newFiltersToDisplay: InventoryFilterData[] = Object.keys( + router.query + ).map(param => parseURLParams(param as string, 'display')); + + setFilters(newFilters); + setDisplayedFilters(newFiltersToDisplay); + }, []); + + const hasFilters = + Object.keys(router.query).length > 0 && + displayedFilters && + displayedFilters.length > 0; + + return ( + <> +
+
+

+ Resources Explorer +

+
setFilterOpen(!filterOpen)} + > + {displayedFilters && displayedFilters?.length > 0 && ( + + {displayedFilters?.length} + + )} + Filters + +
+
+
+ +
+ +
+ + ); +} + +export default DependencyGraphWrapper; diff --git a/dashboard/components/explorer/config.ts b/dashboard/components/explorer/config.ts new file mode 100644 index 000000000..df3dd13ec --- /dev/null +++ b/dashboard/components/explorer/config.ts @@ -0,0 +1,118 @@ +import Cytoscape from 'cytoscape'; + +export const zoomLevelBreakpoint = 1.5; +export const maxZoom = 4; +export const minZoom = 0.25; +export const graphLayoutConfig = { + name: 'cose-bilkent', + // 'draft', 'default' or 'proof" + // - 'draft' fast cooling rate + // - 'default' moderate cooling rate + // - "proof" slow cooling rate + quality: 'proof', + // Whether to include labels in node dimensions. Useful for avoiding label overlap + nodeDimensionsIncludeLabels: true, + // number of ticks per frame; higher is faster but more jerky + refresh: 30, + // Whether to fit the network view after when done + fit: true, + // Padding on fit + padding: 5, + // Whether to enable incremental mode + randomize: true, + // Node repulsion (non overlapping) multiplier + nodeRepulsion: 4500, + // Ideal (intra-graph) edge length + idealEdgeLength: 75, + // Divisor to compute edge forces + edgeElasticity: 0.45, + // Nesting factor (multiplier) to compute ideal edge length for inter-graph edges + nestingFactor: 1, + // Gravity force (constant) + gravity: 0.25, + // Maximum number of iterations to perform + numIter: 2500, + // Whether to tile disconnected nodes + tile: true, + // Type of layout animation. The option set is {'during', 'end', false} + animate: 'end', + // Duration for animate:end + animationDuration: 500, + // Amount of vertical space to put between degree zero nodes during tiling (can also be a function) + tilingPaddingVertical: 100, + // Amount of horizontal space to put between degree zero nodes during tiling (can also be a function) + tilingPaddingHorizontal: 100, + // Gravity range (constant) for compounds + gravityRangeCompound: 1.5, + // Gravity force (constant) for compounds + gravityCompound: 1.0, + // Gravity range (constant) + gravityRange: 3.8, + // Initial cooling factor for incremental layout + initialEnergyOnIncremental: 0.5, + nodeSeparation: 20000 +}; + +export const nodeStyeConfig = { + width(node) { + return Math.max(2, Math.ceil(node.degree(false) / 2)) * 20; + }, + height(node) { + return Math.max(2, Math.ceil(node.degree(false) / 2)) * 20; + }, + shape: 'ellipse', + 'text-opacity': 1, + 'font-size': 17, + 'background-color': 'white', + 'background-image': node => + node.data('provider') === 'AWS' + ? '/assets/img/dependency-graph/aws-node.svg' + : '', + 'background-height': 20, + 'background-width': 20, + 'border-color': '#EDEBEE', + 'border-width': 1, + 'border-style': 'solid', + 'transition-property': 'opacity', + 'transition-duration': 0.2, + 'transition-timing-function': 'linear' +} as Cytoscape.Css.Node; + +export const edgeStyleConfig = { + width: 1, + 'line-fill': 'linear-gradient', + 'line-gradient-stop-colors': ['#008484', '#33CCCC'], + 'line-style': edge => (edge.data('relation') === 'USES' ? 'solid' : 'dashed'), + 'curve-style': 'unbundled-bezier', + 'control-point-distances': edge => edge.data('controlPointDistances'), + 'control-point-weights': [0.25, 0.75] +} as Cytoscape.Css.Edge; + +export const leafStyleConfig = { + width: 28, + height: 28, + opacity: 1 +} as Cytoscape.Css.Node; + +export const edgeAnimationConfig = [ + { + zoom: { level: 1 }, + easing: 'linear', + style: { + 'line-dash-offset': 24, + 'line-dash-pattern': [4, 4] + } + }, + { + duration: 4000 + } +]; + +export const nodeHTMLLabelConfig = { + query: 'node', // cytoscape query selector + halign: 'center', // title vertical position. Can be 'left',''center, 'right' + valign: 'bottom', // title vertical position. Can be 'top',''center, 'bottom' + halignBox: 'center', // title vertical position. Can be 'left',''center, 'right' + valignBox: 'bottom', // title relative box vertical position. Can be 'top',''center, 'bottom' + cssClass: 'dependency-graph-node-label' // any classes will be as attribute of
container for every title +}; diff --git a/dashboard/components/explorer/filter/DependencyGraphFilterDropdown.tsx b/dashboard/components/explorer/filter/DependencyGraphFilterDropdown.tsx new file mode 100644 index 000000000..c2c927f1d --- /dev/null +++ b/dashboard/components/explorer/filter/DependencyGraphFilterDropdown.tsx @@ -0,0 +1,128 @@ +import { useEffect } from 'react'; +import useInventory from '@components/inventory/hooks/useInventory/useInventory'; +import useFilterWizard from '@components/inventory/components/filter/hooks/useFilterWizard'; +import Button from '@components/button/Button'; +import InventoryFilterBreadcrumbs from '@components/inventory/components/filter/InventoryFilterBreadcrumbs'; +import InventoryFilterOperator from '@components/inventory/components/filter/InventoryFilterOperator'; +import InventoryFilterValue from '@components/inventory/components/filter/InventoryFilterValue'; +import DependencyGraphFilterSummary from './DependencyGraphFilterSummary'; +import DependencyGraphFilterField from './DependencyGraphFilterField'; + +type InventoryFilterDropdownProps = { + position: string; + closeDropdownAfterAdd: boolean; + toggle: () => void; +}; + +export default function InventoryFilterDropdown({ + position, + toggle, + closeDropdownAfterAdd +}: InventoryFilterDropdownProps) { + const { setSkippedSearch, router, setToast } = useInventory(); + + const { + // toggle, + step, + goTo, + handleField, + handleOperator, + handleTagKey, + handleValueCheck, + handleValueInput, + costBetween, + handleCostBetween, + inlineError, + data, + resetData, + cleanValues, + filter + } = useFilterWizard({ router, setSkippedSearch }); + + useEffect(() => { + cleanValues(); + }, []); + + return ( + <> + {/* Dropdown transparent backdrop */} +
+
+
+ {/* Filter breadcrumbs */} + +
+ + {/* Filter summary */} + {step !== 0 && data && data.field && ( + + )} +
+ + {/* Filter steps - 1/3 filter field */} + {step === 0 && ( + + )} + + {/* Filter steps - 2/3 filter operator */} + {step === 1 && ( + + )} + + {/* Filter steps - 3/3 filter value */} + {step === 2 && ( +
{ + e.preventDefault(); + filter(); + if (closeDropdownAfterAdd) toggle(); + }} + > +
+ +
+ {inlineError.hasError && ( +

+ {inlineError.message} +

+ )} +
+ +
+
+ )} +
+
+ + ); +} diff --git a/dashboard/components/explorer/filter/DependencyGraphFilterField.tsx b/dashboard/components/explorer/filter/DependencyGraphFilterField.tsx new file mode 100644 index 000000000..36f1b481f --- /dev/null +++ b/dashboard/components/explorer/filter/DependencyGraphFilterField.tsx @@ -0,0 +1,30 @@ +import Button from '@components/button/Button'; +import DependencyGraphFilterFieldOptions from './DependencyGraphFilterOptions'; + +type DependencyGraphFilterFieldProps = { + handleField: (field: string) => void; +}; + +function DependencyGraphFilterField({ + handleField +}: DependencyGraphFilterFieldProps) { + return ( + <> + {DependencyGraphFilterFieldOptions.map((option, idx) => ( + + ))} + + ); +} + +export default DependencyGraphFilterField; diff --git a/dashboard/components/explorer/filter/DependencyGraphFilterOptions.tsx b/dashboard/components/explorer/filter/DependencyGraphFilterOptions.tsx new file mode 100644 index 000000000..b1b817e6f --- /dev/null +++ b/dashboard/components/explorer/filter/DependencyGraphFilterOptions.tsx @@ -0,0 +1,170 @@ +import { ReactNode } from 'react'; + +export type DependencyGraphFilterFieldOptionsProps = { + label: string; + value: string; + icon: ReactNode; +}; + +const DependencyGraphFilterFieldOptions: DependencyGraphFilterFieldOptionsProps[] = + [ + { + label: 'Cloud provider', + value: 'provider', + icon: ( + + + + + ) + }, + { + label: 'Cloud region', + value: 'region', + icon: ( + + + + + + ) + }, + { + label: 'Cloud service', + value: 'service', + icon: ( + + + + ) + }, + { + label: 'Resource relation', + value: 'relations', + icon: ( + + + + + + + + + + + ) + } + ]; + +export default DependencyGraphFilterFieldOptions; diff --git a/dashboard/components/explorer/filter/DependencyGraphFilterSummary.tsx b/dashboard/components/explorer/filter/DependencyGraphFilterSummary.tsx new file mode 100644 index 000000000..c3692840f --- /dev/null +++ b/dashboard/components/explorer/filter/DependencyGraphFilterSummary.tsx @@ -0,0 +1,107 @@ +import { InventoryFilterData } from '@components/inventory/hooks/useInventory/types/useInventoryTypes'; +import Button from '@components/button/Button'; +import CloseIcon from '@components/icons/CloseIcon'; +import DependencyGraphFilterOptions from './DependencyGraphFilterOptions'; + +type DependencyGraphFilterSummaryProps = { + id?: number; + bg?: 'white'; + data: InventoryFilterData; + deleteFilter?: (idx: number) => void; + resetData?: () => void; +}; + +function DependencyGraphFilterSummary({ + id, + bg, + data, + deleteFilter, + resetData +}: DependencyGraphFilterSummaryProps) { + const index = DependencyGraphFilterOptions.findIndex( + option => option.value === data.field + ); + + function getField(param: 'icon' | 'label') { + if (param === 'icon') return DependencyGraphFilterOptions[index].icon; + if (param === 'label') return DependencyGraphFilterOptions[index].label; + return param; + } + + function getOperator(param: InventoryFilterData['operator']) { + if (param === 'IS') return 'is'; + if (param === 'IS_NOT') return 'is not'; + if (param === 'CONTAINS') return 'contains'; + if (param === 'NOT_CONTAINS') return 'does not contain'; + if (param === 'IS_EMPTY' && data.field !== 'tags') return 'is empty'; + if (param === 'IS_EMPTY' && data.field === 'tags') return 'which are empty'; + if (param === 'IS_NOT_EMPTY' && data.field !== 'tags') + return 'is not empty'; + if (param === 'IS_NOT_EMPTY' && data.field === 'tags') + return 'which are not empty'; + if (param === 'EQUAL') return 'is equal to'; + if (param === 'BETWEEN') return 'is between'; + if (param === 'GREATER_THAN') return 'is greater than'; + if (param === 'LESS_THAN') return 'is less than'; + if (param === 'EXISTS') return 'does exist'; + if (param === 'NOT_EXISTS') return 'does not exist'; + return param; + } + + return ( +
+
+
{getField('icon')}
+

{getField('label')}

+ {data.tagKey &&

: {data.tagKey}

} + {data.operator && ( + <> + : + {getOperator(data.operator)} + + )} + {data.values && + data.values.length > 0 && + data.values.map((value, idx) => ( +

+ {idx === 0 && :} + + {data.field === 'cost' && '$'} + {value} + + {data.values.length > 1 && idx < data.values.length - 1 && ( + + {data.field === 'cost' && data.operator === 'BETWEEN' + ? 'and' + : 'or'} + + )} +

+ ))} +
+ {(deleteFilter || resetData) && ( +
+ +
+ )} +
+ ); +} + +export default DependencyGraphFilterSummary; diff --git a/dashboard/components/explorer/filter/DependendencyGraphFilter.tsx b/dashboard/components/explorer/filter/DependendencyGraphFilter.tsx new file mode 100644 index 000000000..374346d4b --- /dev/null +++ b/dashboard/components/explorer/filter/DependendencyGraphFilter.tsx @@ -0,0 +1,95 @@ +import { ReactNode, useState } from 'react'; +import { NextRouter } from 'next/router'; +import useFilterWizard from '@components/inventory/components/filter/hooks/useFilterWizard'; +import useInventory from '@components/inventory/hooks/useInventory/useInventory'; +import { InventoryFilterData } from '@components/inventory/hooks/useInventory/types/useInventoryTypes'; +import PlusIcon from '@components/icons/PlusIcon'; +import Button from '@components/button/Button'; +import CloseIcon from '@components/icons/CloseIcon'; +import DependencyGraphFilterSummary from './DependencyGraphFilterSummary'; +import DependencyGraphFilterDropdown from './DependencyGraphFilterDropdown'; + +type DependendencyGraphFilterProps = { + hasFilters: boolean | undefined; + displayedFilters: InventoryFilterData[] | undefined; + deleteFilter: (idx: number) => void; + router: NextRouter; + children?: ReactNode; +}; + +function DependendencyGraphFilter({ + hasFilters, + displayedFilters, + deleteFilter, + router, + children +}: DependendencyGraphFilterProps) { + const [skippedSearch, setSkippedSearch] = useState(0); + const { toggle, isOpen } = useFilterWizard({ router, setSkippedSearch }); + + return ( +
+ {!hasFilters ? ( + <> +
+ + Filter +
+ {isOpen && ( + + )} + + ) : ( +
+
Filters
+ {displayedFilters && + displayedFilters.map((activeFilter, idx) => ( + + ))} + +
+
+
+ +
+ {isOpen && ( + + )} +
+ +
+
router.push(router.pathname)} + > + + Clear filters + + +
+
+
+ )} + + {children} +
+ ); +} + +export default DependendencyGraphFilter; diff --git a/dashboard/components/explorer/hooks/useDependencyGraph.tsx b/dashboard/components/explorer/hooks/useDependencyGraph.tsx new file mode 100644 index 000000000..265c63404 --- /dev/null +++ b/dashboard/components/explorer/hooks/useDependencyGraph.tsx @@ -0,0 +1,142 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; + +import { InventoryFilterData } from '@components/inventory/hooks/useInventory/types/useInventoryTypes'; +import settingsService from '@services/settingsService'; + +export type ReactFlowData = { + nodes: any[]; + edges: any[]; +}; + +// converting the json object into data that reactflow needs +// TODO - based on selected library +function GetData(res: any) { + const d = { + nodes: [], + edges: [] + } as ReactFlowData; + res.forEach((ele: any) => { + // check if node exist already + if (d.nodes.findIndex(element => element.id === ele.resourceId) === -1) { + const a = { + data: { + label: ele.name, + service: ele.service, + provider: 'AWS', + id: ele.resourceId, + isRoot: true + } + }; + d.nodes.push(a); + } + + ele.relations.forEach((rel: any) => { + // check for other node exists + if (d.nodes.findIndex(element => element.id === rel.resourceId) === -1) { + const a = { + data: { + id: rel.resourceId, + label: rel.name, + service: ele.service, + type: rel.type, + provider: 'AWS', // when supporting new provider this could be made dynamic + isRoot: false + } + }; + d.nodes.push(a); + } + const edge = { + data: { + id: `${ele.resourceId}-${rel.resourceId}`, + source: ele.resourceId, + target: rel.resourceId, + relation: rel.relation, + label: `${rel.relation.toLowerCase()} ${rel.type}`, + type: rel.type, + controlPointDistances: [ + Math.floor(Math.random() * 30), + Math.floor(Math.random() * 31) - 30 + ] + } + }; + d.edges.push(edge); + }); + }); + return d; +} + +function useDependencyGraph() { + const [loading, setLoading] = useState(true); + const [data, setData] = useState(); + const [error, setError] = useState(false); + const [filters, setFilters] = useState([]); + const [displayedFilters, setDisplayedFilters] = + useState(); + + const router = useRouter(); + + function fetch() { + if (!loading) { + setLoading(true); + } + + if (error) { + setError(false); + } + + settingsService.getRelations(filters).then(res => { + if (res === Error) { + setLoading(false); + setError(true); + } else { + setLoading(false); + setData(GetData(res)); + } + }); + } + + function deleteFilter(idx: number) { + const updatedFilters: InventoryFilterData[] = [...filters!]; + updatedFilters.splice(idx, 1); + const url = updatedFilters + .map( + filter => + `${filter.field}${`:${filter.operator}`}${ + filter.values.length > 0 ? `:${filter.values}` : '' + }` + ) + .join('&'); + router.push(url ? `?${url}` : '', undefined, { shallow: true }); + } + + const loadingFilters = + Object.keys(router.query).length > 0 && !displayedFilters && !error; + + const hasFilters = + Object.keys(router.query).length > 0 && + displayedFilters && + displayedFilters.length > 0; + + useEffect(() => { + fetch(); + }, []); + + useEffect(() => { + fetch(); + }, [filters, displayedFilters]); + + return { + loading, + data, + error, + fetch, + filters, + displayedFilters, + setDisplayedFilters, + deleteFilter, + setFilters + }; +} + +export default useDependencyGraph; diff --git a/dashboard/components/icons/AlertCircleIcon.tsx b/dashboard/components/icons/AlertCircleIcon.tsx new file mode 100644 index 000000000..5763df243 --- /dev/null +++ b/dashboard/components/icons/AlertCircleIcon.tsx @@ -0,0 +1,35 @@ +import { SVGProps } from 'react'; + +const AlertCircleIcon = (props: SVGProps) => ( + + + + + + +); + +export default AlertCircleIcon; diff --git a/dashboard/components/icons/AlertCircleIconFilled.tsx b/dashboard/components/icons/AlertCircleIconFilled.tsx new file mode 100644 index 000000000..5e9559412 --- /dev/null +++ b/dashboard/components/icons/AlertCircleIconFilled.tsx @@ -0,0 +1,24 @@ +import { SVGProps } from 'react'; + +const AlertCircleIconFilled = (props: SVGProps) => ( + + + + +); + +export default AlertCircleIconFilled; diff --git a/dashboard/components/icons/ArrowDownIcon.tsx b/dashboard/components/icons/ArrowDownIcon.tsx new file mode 100644 index 000000000..254be3ea0 --- /dev/null +++ b/dashboard/components/icons/ArrowDownIcon.tsx @@ -0,0 +1,31 @@ +import { SVGProps } from 'react'; + +const ArrowDownIcon = (props: SVGProps) => ( + + + + +); + +export default ArrowDownIcon; diff --git a/dashboard/components/icons/DocumentTextIcon.tsx b/dashboard/components/icons/DocumentTextIcon.tsx new file mode 100644 index 000000000..15ee47823 --- /dev/null +++ b/dashboard/components/icons/DocumentTextIcon.tsx @@ -0,0 +1,47 @@ +import { SVGProps } from 'react'; + +const DocumentTextIcon = (props: SVGProps) => ( + + + + + + +); + +export default DocumentTextIcon; diff --git a/dashboard/components/icons/Folder2Icon.tsx b/dashboard/components/icons/Folder2Icon.tsx new file mode 100644 index 000000000..d307d2ac9 --- /dev/null +++ b/dashboard/components/icons/Folder2Icon.tsx @@ -0,0 +1,29 @@ +import { SVGProps } from 'react'; + +const Folder2Icon = (props: SVGProps) => ( + + + + +); + +export default Folder2Icon; diff --git a/dashboard/components/icons/KeyIcon.tsx b/dashboard/components/icons/KeyIcon.tsx new file mode 100644 index 000000000..53181b28a --- /dev/null +++ b/dashboard/components/icons/KeyIcon.tsx @@ -0,0 +1,38 @@ +import { SVGProps } from 'react'; + +const KeyIcon = (props: SVGProps) => ( + + + + + +); + +export default KeyIcon; diff --git a/dashboard/components/icons/More2Icon.tsx b/dashboard/components/icons/More2Icon.tsx new file mode 100644 index 000000000..ae6fe87a4 --- /dev/null +++ b/dashboard/components/icons/More2Icon.tsx @@ -0,0 +1,27 @@ +import { SVGProps } from 'react'; + +const More2Icon = (props: SVGProps) => ( + + + + + +); + +export default More2Icon; diff --git a/dashboard/components/icons/RecordCircleIcon.tsx b/dashboard/components/icons/RecordCircleIcon.tsx new file mode 100644 index 000000000..a9de91820 --- /dev/null +++ b/dashboard/components/icons/RecordCircleIcon.tsx @@ -0,0 +1,30 @@ +import { SVGProps } from 'react'; + +const RecordCircleIcon = (props: SVGProps) => ( + + + + +); + +export default RecordCircleIcon; diff --git a/dashboard/components/icons/ShieldSecurityIcon.tsx b/dashboard/components/icons/ShieldSecurityIcon.tsx new file mode 100644 index 000000000..7ece47d88 --- /dev/null +++ b/dashboard/components/icons/ShieldSecurityIcon.tsx @@ -0,0 +1,38 @@ +import { SVGProps } from 'react'; + +const ShieldSecurityIcon = (props: SVGProps) => ( + + + + + +); + +export default ShieldSecurityIcon; diff --git a/dashboard/components/icons/VariableIcon.tsx b/dashboard/components/icons/VariableIcon.tsx new file mode 100644 index 000000000..327e09b93 --- /dev/null +++ b/dashboard/components/icons/VariableIcon.tsx @@ -0,0 +1,26 @@ +import { SVGProps } from 'react'; + +const VariableIcon = (props: SVGProps) => ( + + + + +); + +export default VariableIcon; diff --git a/dashboard/components/input/Input.tsx b/dashboard/components/input/Input.tsx index b23692999..de6ab5534 100644 --- a/dashboard/components/input/Input.tsx +++ b/dashboard/components/input/Input.tsx @@ -68,7 +68,7 @@ function Input({
- +
{isOpen && (
- + + Clear filters + + +
)} diff --git a/dashboard/components/inventory/components/filter/InventoryFilterField.tsx b/dashboard/components/inventory/components/filter/InventoryFilterField.tsx index cd275e077..d64ebeccf 100644 --- a/dashboard/components/inventory/components/filter/InventoryFilterField.tsx +++ b/dashboard/components/inventory/components/filter/InventoryFilterField.tsx @@ -1,4 +1,4 @@ -import Button from '../../../button/Button'; +import Button from '@components/button/Button'; import inventoryFilterFieldOptions from './InventoryFilterFieldOptions'; type InventoryFilterFieldProps = { diff --git a/dashboard/components/inventory/components/filter/InventoryFilterOperator.tsx b/dashboard/components/inventory/components/filter/InventoryFilterOperator.tsx index 42eb2e873..3f997d0dd 100644 --- a/dashboard/components/inventory/components/filter/InventoryFilterOperator.tsx +++ b/dashboard/components/inventory/components/filter/InventoryFilterOperator.tsx @@ -54,6 +54,21 @@ const inventoryFilterOperatorCostOptions: InventoryFilterOperatorOptionsProps[] } ]; +const inventoryFilterRelationsOptions: InventoryFilterOperatorOptionsProps[] = [ + { + label: 'is equal to', + value: 'EQUAL' + }, + { + label: 'is greater than', + value: 'GREATER_THAN' + }, + { + label: 'is less than', + value: 'LESS_THAN' + } +]; + function InventoryFilterOperator({ data, handleOperator, @@ -80,6 +95,7 @@ function InventoryFilterOperator({ {/* Operators list which are not tags or cost */} {data.field !== 'tags' && data.field !== 'cost' && + data.field !== 'relations' && inventoryFilterOperatorOptions.map((option, idx) => ( + ))} ); } diff --git a/dashboard/components/inventory/components/filter/InventoryFilterSummary.tsx b/dashboard/components/inventory/components/filter/InventoryFilterSummary.tsx index 9fb4a2ebe..b5566743e 100644 --- a/dashboard/components/inventory/components/filter/InventoryFilterSummary.tsx +++ b/dashboard/components/inventory/components/filter/InventoryFilterSummary.tsx @@ -52,7 +52,7 @@ function InventoryFilterSummary({
{getField('icon')}
@@ -61,9 +61,7 @@ function InventoryFilterSummary({ {data.operator && ( <> : - - {getOperator(data.operator)} - + {getOperator(data.operator)} )} {data.values && @@ -76,7 +74,7 @@ function InventoryFilterSummary({ {value} {data.values.length > 1 && idx < data.values.length - 1 && ( - + {data.field === 'cost' && data.operator === 'BETWEEN' ? 'and' : 'or'} @@ -98,7 +96,7 @@ function InventoryFilterSummary({ } }} > - +
)} diff --git a/dashboard/components/inventory/components/filter/InventoryFilterValue.tsx b/dashboard/components/inventory/components/filter/InventoryFilterValue.tsx index 313e03d28..0ed019754 100644 --- a/dashboard/components/inventory/components/filter/InventoryFilterValue.tsx +++ b/dashboard/components/inventory/components/filter/InventoryFilterValue.tsx @@ -121,6 +121,7 @@ function InventoryFilterValue({ {/* Display input for resource name and tag values */} {!options && data.field !== 'cost' && + data.field !== 'relations' && data.operator !== 'IS_EMPTY' && data.operator !== 'IS_NOT_EMPTY' && data.operator !== 'EXISTS' && @@ -140,21 +141,23 @@ function InventoryFilterValue({ )} {/* Display input for cost when is equal, greater or less than */} - {!options && data.field === 'cost' && data.operator !== 'BETWEEN' && ( -
- -
- )} + {!options && + (data.field === 'cost' || data.field === 'relations') && + data.operator !== 'BETWEEN' && ( +
+ +
+ )} {/* Display input for cost when is between */} {!options && data.field === 'cost' && data.operator === 'BETWEEN' && ( diff --git a/dashboard/components/inventory/components/filter/hooks/useFilterWizard.tsx b/dashboard/components/inventory/components/filter/hooks/useFilterWizard.tsx index f77ad9eaf..ff5327e8c 100644 --- a/dashboard/components/inventory/components/filter/hooks/useFilterWizard.tsx +++ b/dashboard/components/inventory/components/filter/hooks/useFilterWizard.tsx @@ -144,6 +144,16 @@ function useFilterWizard({ router, setSkippedSearch }: InventoryFilterProps) { undefined, { shallow: true } ); + } else if (router.asPath === '/explorer/') { + router.push( + `?${data.field === 'tag' ? `tag:${data.tagKey}` : data.field}:${ + data.operator + }${ + data.values.length > 0 ? `:${data.values.map(value => value)}` : '' + }`, + undefined, + { shallow: true } + ); } else { router.push( `${router.asPath}&${ diff --git a/dashboard/components/layout/Layout.tsx b/dashboard/components/layout/Layout.tsx index f85a7f67e..26c6b424e 100644 --- a/dashboard/components/layout/Layout.tsx +++ b/dashboard/components/layout/Layout.tsx @@ -1,7 +1,9 @@ import * as Sentry from '@sentry/react'; +import classNames from 'classnames'; import { BrowserTracing } from '@sentry/tracing'; import { useRouter } from 'next/router'; import { ReactNode, useEffect } from 'react'; +import settingsService from '@services/settingsService'; import environment from '../../environments/environment'; import Banner from '../banner/Banner'; import useGithubStarBanner from '../banner/hooks/useGithubStarBanner'; @@ -24,6 +26,18 @@ function Layout({ children }: LayoutProps) { const router = useRouter(); const canRender = !error && !hasNoAccounts; + useEffect(() => { + settingsService.getOnboardingStatus().then(res => { + if (res.onboarded === true && res.status === 'COMPLETE') { + router.replace('/dashboard/'); + } else if (res.onboarded === false && res.status === 'PENDING_DATABASE') { + router.replace('/onboarding/choose-database'); + } else if (res.onboarded === false && res.status === 'PENDING_ACCOUNTS') { + router.replace('/onboarding/choose-cloud'); + } + }); + }, []); + useEffect(() => { if (telemetry?.telemetry_enabled && environment.production) { Sentry.init({ @@ -37,6 +51,10 @@ function Layout({ children }: LayoutProps) { } }, [telemetry]); + const betaFlagOnboardingWizard = true; + const isOnboarding = + betaFlagOnboardingWizard && router.pathname.startsWith('/onboarding'); + return ( - - -
- {canRender && children} + {isOnboarding && <>{children}} + + {!isOnboarding && ( + <> + + +
+ {canRender && children} + + {hasNoAccounts && betaFlagOnboardingWizard && !isOnboarding && ( + { + router.push('/onboarding/choose-cloud'); + }} + actionLabel="Begin Onboarding" + secondaryAction={() => { + router.push( + 'https://github.com/tailwarden/komiser/issues/new/choose' + ); + }} + secondaryActionLabel="Report an issue" + mascotPose="greetings" + /> + )} - {hasNoAccounts && ( - { - router.push( - 'https://docs.komiser.io/docs/introduction/getting-started?utm_source=komiser&utm_medium=referral&utm_campaign=static' - ); - }} - actionLabel="Guide to connect account" - secondaryAction={() => { - router.push( - 'https://github.com/tailwarden/komiser/issues/new/choose' - ); - }} - secondaryActionLabel="Report an issue" - mascotPose="thinking" - /> - )} + {/* This block would be removed when onboarding Wizard is stable leaving the block above */} + {hasNoAccounts && !betaFlagOnboardingWizard && ( + { + router.push( + 'https://docs.komiser.io/docs/introduction/getting-started?utm_source=komiser&utm_medium=referral&utm_campaign=static' + ); + }} + actionLabel="Guide to connect account" + secondaryAction={() => { + router.push( + 'https://github.com/tailwarden/komiser/issues/new/choose' + ); + }} + secondaryActionLabel="Report an issue" + mascotPose="thinking" + /> + )} + {/* This block would be removed when onboarding Wizard is stable leaving the block above */} - {error && ( - router.reload()} - > - Refresh the page - - } - /> - )} -
+ {error && ( + router.reload()} + > + Refresh the page + + } + /> + )} +
+ + )}
); } diff --git a/dashboard/components/layout/context/GlobalAppContext.tsx b/dashboard/components/layout/context/GlobalAppContext.tsx index 31db2995f..c5c249519 100644 --- a/dashboard/components/layout/context/GlobalAppContext.tsx +++ b/dashboard/components/layout/context/GlobalAppContext.tsx @@ -15,6 +15,7 @@ export type GlobalAppContextProps = { error: boolean; hasNoAccounts: boolean; fetch: () => void; + betaFlagOnboardingWizard: boolean; }; export const initialContext = { @@ -24,7 +25,8 @@ export const initialContext = { data: undefined, error: false, hasNoAccounts: false, - fetch: () => {} + fetch: () => {}, + betaFlagOnboardingWizard: false }; const GlobalAppContext = createContext(initialContext); diff --git a/dashboard/components/navbar/Navbar.tsx b/dashboard/components/navbar/Navbar.tsx index f0d71668f..0aa081c2c 100644 --- a/dashboard/components/navbar/Navbar.tsx +++ b/dashboard/components/navbar/Navbar.tsx @@ -4,18 +4,30 @@ import { useRouter } from 'next/router'; import { useContext } from 'react'; import GlobalAppContext from '../layout/context/GlobalAppContext'; +interface NavItem { + label: string; + href: string; +} + function Navbar() { - const { displayBanner } = useContext(GlobalAppContext); + const { displayBanner, betaFlagOnboardingWizard } = + useContext(GlobalAppContext); const router = useRouter(); - const nav = [ + // TODO: (onboarding-wizard) Remove the betaFlagOnboardingWizard conditional when feature is stable + const nav: NavItem[] = [ { label: 'Dashboard', href: '/dashboard' }, - { label: 'Inventory', href: '/inventory' } - ]; + { label: 'Inventory', href: '/inventory' }, + betaFlagOnboardingWizard + ? { label: 'Cloud Accounts', href: '/cloud-accounts' } + : null, + { label: 'Explorer', href: '/explorer' } + ].filter(item => item !== null) as NavItem[]; + return (