diff --git a/frontend/webapp/.eslintrc.json b/frontend/webapp/.eslintrc.json index 2e1cbf9e1..437e574eb 100644 --- a/frontend/webapp/.eslintrc.json +++ b/frontend/webapp/.eslintrc.json @@ -2,6 +2,13 @@ "extends": "next/core-web-vitals", "rules": { "semi": ["error", "always"], - "quotes": ["error", "single", { "avoidEscape": true }] + "quotes": [ + "error", + "single", + { + "avoidEscape": true, + "allowTemplateLiterals": true + } + ] } } diff --git a/frontend/webapp/app/layout.tsx b/frontend/webapp/app/layout.tsx index dfee65de7..bb8b58cab 100644 --- a/frontend/webapp/app/layout.tsx +++ b/frontend/webapp/app/layout.tsx @@ -8,7 +8,7 @@ import { ThemeProvider } from 'styled-components'; import { NotificationManager } from '@/components'; import ReduxProvider from '@/store/redux-provider'; import { QueryClient, QueryClientProvider } from 'react-query'; -import { ThemeProviderWrapper } from '@keyval-dev/design-system'; +// import { ThemeProviderWrapper } from '@keyval-dev/design-system'; const LAYOUT_STYLE: React.CSSProperties = { margin: 0, @@ -18,11 +18,7 @@ const LAYOUT_STYLE: React.CSSProperties = { height: '100vh', }; -export default function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { +export default function RootLayout({ children }: { children: React.ReactNode }) { const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -35,7 +31,7 @@ export default function RootLayout({ useSSE(); return ( - + @@ -43,7 +39,7 @@ export default function RootLayout({ {/* */} {children} - {/* */} + {/* */} diff --git a/frontend/webapp/components/notification/notification-manager.tsx b/frontend/webapp/components/notification/notification-manager.tsx index 845928853..b091ea56f 100644 --- a/frontend/webapp/components/notification/notification-manager.tsx +++ b/frontend/webapp/components/notification/notification-manager.tsx @@ -1,7 +1,6 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { useSelector } from 'react-redux'; - -import Notification from './notification'; +// import Notification from './notification'; import styled from 'styled-components'; import { RootState } from '@/store'; @@ -16,17 +15,20 @@ const NotificationsWrapper = styled.div` `; export const NotificationManager: React.FC = () => { - const notifications = useSelector( - (state: RootState) => state.notification.notifications - ); + const notifications = useSelector((state: RootState) => state.notification.notifications); + + // temporary - until we fix the "theme" error on import from "design.system" + useEffect(() => { + if (notifications.length) alert(notifications[notifications.length - 1].message); + }, [notifications.length]); return ( - {notifications + {/* {notifications .filter((notification) => notification.isNew) .map((notification) => ( - ))} + ))} */} ); }; diff --git a/frontend/webapp/containers/main/actions/choose-action-body/custom-fields/error-sampler.tsx b/frontend/webapp/containers/main/actions/choose-action-body/custom-fields/error-sampler.tsx new file mode 100644 index 000000000..4c0b0d876 --- /dev/null +++ b/frontend/webapp/containers/main/actions/choose-action-body/custom-fields/error-sampler.tsx @@ -0,0 +1,43 @@ +import React, { useMemo } from 'react'; +import { safeJsonParse } from '@/utils'; +import { Input } from '@/reuseable-components'; +import { FieldTitle, FieldWrapper } from './styled'; + +type Props = { + value: string; + setValue: (value: string) => void; +}; + +type Parsed = { + fallback_sampling_ratio: number; +}; + +const MIN = 0, + MAX = 100; + +const ErrorSampler: React.FC = ({ value, setValue }) => { + const mappedValue = useMemo(() => safeJsonParse(value, { fallback_sampling_ratio: 0 }).fallback_sampling_ratio, [value]); + + const handleChange = (val: string) => { + let num = Number(val); + + if (Number.isNaN(num) || num < MIN || num > MAX) { + num = MIN; + } + + const payload: Parsed = { + fallback_sampling_ratio: num, + }; + + setValue(JSON.stringify(payload)); + }; + + return ( + + Fallback sampling ratio + handleChange(v)} /> + + ); +}; + +export default ErrorSampler; diff --git a/frontend/webapp/containers/main/actions/choose-action-body/custom-fields/index.tsx b/frontend/webapp/containers/main/actions/choose-action-body/custom-fields/index.tsx index ccd1a04ff..213d04b6a 100644 --- a/frontend/webapp/containers/main/actions/choose-action-body/custom-fields/index.tsx +++ b/frontend/webapp/containers/main/actions/choose-action-body/custom-fields/index.tsx @@ -1,8 +1,11 @@ +import React from 'react'; import { ActionsType } from '@/types'; import AddClusterInfo from './add-cluster-info'; import DeleteAttributes from './delete-attributes'; import RenameAttributes from './rename-attributes'; import PiiMasking from './pii-masking'; +import ErrorSampler from './error-sampler'; +import ProbabilisticSampler from './probabilistic-sampler'; interface ActionCustomFieldsProps { actionType?: ActionsType; @@ -10,36 +13,29 @@ interface ActionCustomFieldsProps { setValue: (value: string) => void; } -const ActionCustomFields: React.FC = ({ actionType, value, setValue }) => { - switch (actionType) { - case ActionsType.ADD_CLUSTER_INFO: { - return ; - } - - case ActionsType.DELETE_ATTRIBUTES: { - return ; - } - - case ActionsType.RENAME_ATTRIBUTES: { - return ; - } +type ComponentProps = { + value: string; + setValue: (value: string) => void; +}; - case ActionsType.PII_MASKING: { - return ; - } +type ComponentType = React.FC | null; - case ActionsType.ERROR_SAMPLER: - return null; +const componentsMap: Record = { + [ActionsType.ADD_CLUSTER_INFO]: AddClusterInfo, + [ActionsType.DELETE_ATTRIBUTES]: DeleteAttributes, + [ActionsType.RENAME_ATTRIBUTES]: RenameAttributes, + [ActionsType.PII_MASKING]: PiiMasking, + [ActionsType.ERROR_SAMPLER]: ErrorSampler, + [ActionsType.PROBABILISTIC_SAMPLER]: ProbabilisticSampler, + [ActionsType.LATENCY_SAMPLER]: null, +}; - case ActionsType.PROBABILISTIC_SAMPLER: - return null; +const ActionCustomFields: React.FC = ({ actionType, value, setValue }) => { + if (!actionType) return null; - case ActionsType.LATENCY_SAMPLER: - return null; + const Component = componentsMap[actionType]; - default: - return null; - } + return Component ? : null; }; export default ActionCustomFields; diff --git a/frontend/webapp/containers/main/actions/choose-action-body/custom-fields/pii-masking.tsx b/frontend/webapp/containers/main/actions/choose-action-body/custom-fields/pii-masking.tsx index 21cde2910..fe02e3f08 100644 --- a/frontend/webapp/containers/main/actions/choose-action-body/custom-fields/pii-masking.tsx +++ b/frontend/webapp/containers/main/actions/choose-action-body/custom-fields/pii-masking.tsx @@ -1,7 +1,8 @@ -import React, { useMemo } from 'react'; +import styled from 'styled-components'; import { safeJsonParse } from '@/utils'; -import { InputList } from '@/reuseable-components'; +import { Checkbox } from '@/reuseable-components'; import { FieldTitle, FieldWrapper } from './styled'; +import React, { useEffect, useMemo, useState } from 'react'; type Props = { value: string; @@ -12,21 +13,61 @@ type Parsed = { piiCategories: string[]; }; +const ListContainer = styled.div` + display: flex; + flex-direction: row; + gap: 32px; +`; + +const strictPicklist = [ + { + id: 'CREDIT_CARD', + label: 'Credit Card', + }, +]; + const PiiMasking: React.FC = ({ value, setValue }) => { const mappedValue = useMemo(() => safeJsonParse(value, { piiCategories: [] }).piiCategories, [value]); + const [isLastSelection, setIsLastSelection] = useState(mappedValue.length === 1); + + useEffect(() => { + if (!mappedValue.length) { + const payload: Parsed = { + piiCategories: strictPicklist.map(({ id }) => id), + }; + + setValue(JSON.stringify(payload)); + setIsLastSelection(payload.piiCategories.length === 1); + } + // eslint-disable-next-line + }, []); + + const handleChange = (id: string, isAdd: boolean) => { + const arr = isAdd ? [...mappedValue, id] : mappedValue.filter((str) => str !== id); - const handleChange = (arr: string[]) => { const payload: Parsed = { piiCategories: arr, }; setValue(JSON.stringify(payload)); + setIsLastSelection(arr.length === 1); }; return ( Attributes to mask - + + + {strictPicklist.map(({ id, label }) => ( + handleChange(id, bool)} + /> + ))} + ); }; diff --git a/frontend/webapp/containers/main/actions/choose-action-body/custom-fields/probabilistic-sampler.tsx b/frontend/webapp/containers/main/actions/choose-action-body/custom-fields/probabilistic-sampler.tsx new file mode 100644 index 000000000..fe7f69db7 --- /dev/null +++ b/frontend/webapp/containers/main/actions/choose-action-body/custom-fields/probabilistic-sampler.tsx @@ -0,0 +1,39 @@ +import React, { useMemo } from 'react'; +import { safeJsonParse } from '@/utils'; +import { Input } from '@/reuseable-components'; +import { FieldTitle, FieldWrapper } from './styled'; + +type Props = { + value: string; + setValue: (value: string) => void; +}; + +type Parsed = { + sampling_percentage: string; +}; + +const MIN = 0, + MAX = 100; + +const ProbabilisticSampler: React.FC = ({ value, setValue }) => { + const mappedValue = useMemo(() => safeJsonParse(value, { sampling_percentage: '0' }).sampling_percentage, [value]); + + const handleChange = (val: string) => { + const num = Math.max(MIN, Math.min(Number(val), MAX)) || MIN; + + const payload: Parsed = { + sampling_percentage: String(num), + }; + + setValue(JSON.stringify(payload)); + }; + + return ( + + Sampling percentage + handleChange(v)} /> + + ); +}; + +export default ProbabilisticSampler; diff --git a/frontend/webapp/containers/main/actions/choose-action-body/index.tsx b/frontend/webapp/containers/main/actions/choose-action-body/index.tsx index 6190373e3..1f95e57d8 100644 --- a/frontend/webapp/containers/main/actions/choose-action-body/index.tsx +++ b/frontend/webapp/containers/main/actions/choose-action-body/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import styled from 'styled-components'; +import { type ActionInput } from '@/types'; import ActionCustomFields from './custom-fields'; -import { ActionFormData } from '@/hooks/actions/useActionFormData'; import { type ActionOption } from '../choose-action-modal/action-options'; import { DocsButton, Input, Text, TextArea } from '@/reuseable-components'; import { MonitoringCheckboxes } from '@/reuseable-components/monitoring-checkboxes'; @@ -23,8 +23,8 @@ const FieldTitle = styled(Text)` interface ChooseActionContentProps { action: ActionOption; - formData: ActionFormData; - handleFormChange: (key: keyof ActionFormData, val: any) => void; + formData: ActionInput; + handleFormChange: (key: keyof ActionInput, val: any) => void; } const ChooseActionBody: React.FC = ({ action, formData, handleFormChange }) => { diff --git a/frontend/webapp/containers/main/actions/choose-action-modal/action-options.ts b/frontend/webapp/containers/main/actions/choose-action-modal/action-options.ts index 8b9f31f7b..1fb1b3143 100644 --- a/frontend/webapp/containers/main/actions/choose-action-modal/action-options.ts +++ b/frontend/webapp/containers/main/actions/choose-action-modal/action-options.ts @@ -1,5 +1,5 @@ import { ActionsType } from '@/types'; -import { SignalLowercase } from '@/utils'; +import { SignalUppercase } from '@/utils'; export type ActionOption = { id: string; @@ -10,7 +10,7 @@ export type ActionOption = { docsDescription?: string; icon?: string; items?: ActionOption[]; - allowedSignals?: SignalLowercase[]; + allowedSignals?: SignalUppercase[]; }; export const ACTION_OPTIONS: ActionOption[] = [ @@ -23,7 +23,7 @@ export const ACTION_OPTIONS: ActionOption[] = [ docsEndpoint: '/pipeline/actions/attributes/addclusterinfo', docsDescription: 'The “Add Cluster Info” Odigos Action can be used to add resource attributes to telemetry signals originated from the k8s cluster where the Odigos is running.', - allowedSignals: ['traces', 'metrics', 'logs'], + allowedSignals: ['TRACES', 'METRICS', 'LOGS'], }, { id: 'delete_attribute', @@ -33,7 +33,7 @@ export const ACTION_OPTIONS: ActionOption[] = [ icon: '/icons/actions/deleteattribute.svg', docsEndpoint: '/pipeline/actions/attributes/deleteattribute', docsDescription: 'The “Delete Attribute” Odigos Action can be used to delete attributes from logs, metrics, and traces.', - allowedSignals: ['traces', 'metrics', 'logs'], + allowedSignals: ['TRACES', 'METRICS', 'LOGS'], }, { id: 'rename_attribute', @@ -44,7 +44,7 @@ export const ACTION_OPTIONS: ActionOption[] = [ docsEndpoint: '/pipeline/actions/attributes/rename-attribute', docsDescription: 'The “Rename Attribute” Odigos Action can be used to rename attributes from logs, metrics, and traces. Different instrumentations might use different attribute names for similar information. This action let’s you to consolidate the names across your cluster.', - allowedSignals: ['traces', 'metrics', 'logs'], + allowedSignals: ['TRACES', 'METRICS', 'LOGS'], }, { id: 'pii-masking', @@ -54,7 +54,7 @@ export const ACTION_OPTIONS: ActionOption[] = [ icon: '/icons/actions/piimasking.svg', docsEndpoint: '/pipeline/actions/attributes/piimasking', docsDescription: 'The “PII Masking” Odigos Action can be used to mask PII data from span attribute values.', - allowedSignals: ['traces'], + allowedSignals: ['TRACES'], }, { id: 'sampler', @@ -68,7 +68,7 @@ export const ACTION_OPTIONS: ActionOption[] = [ type: ActionsType.ERROR_SAMPLER, docsEndpoint: '/pipeline/actions/sampling/errorsampler', docsDescription: 'The “Error Sampler” Odigos Action is a Global Action that supports error sampling by filtering out non-error traces.', - allowedSignals: ['traces'], + allowedSignals: ['TRACES'], }, { id: 'probabilistic-sampler', @@ -78,7 +78,7 @@ export const ACTION_OPTIONS: ActionOption[] = [ docsEndpoint: '/pipeline/actions/sampling/probabilisticsampler', docsDescription: 'The “Probabilistic Sampler” Odigos Action supports probabilistic sampling based on a configured sampling percentage applied to the TraceID.', - allowedSignals: ['traces'], + allowedSignals: ['TRACES'], }, { id: 'latency-action', @@ -88,7 +88,7 @@ export const ACTION_OPTIONS: ActionOption[] = [ docsEndpoint: '/pipeline/actions/sampling/latencysampler', docsDescription: 'The “Latency Sampler” Odigos Action is an Endpoint Action that samples traces based on their duration for a specific service and endpoint (HTTP route) filter.', - allowedSignals: ['traces'], + allowedSignals: ['TRACES'], }, ], }, diff --git a/frontend/webapp/containers/main/actions/choose-action-modal/index.tsx b/frontend/webapp/containers/main/actions/choose-action-modal/index.tsx index 65cca01e8..4f9b16826 100644 --- a/frontend/webapp/containers/main/actions/choose-action-modal/index.tsx +++ b/frontend/webapp/containers/main/actions/choose-action-modal/index.tsx @@ -1,9 +1,9 @@ import styled from 'styled-components'; -import React, { useRef, useState } from 'react'; -import { useActionFormData } from '@/hooks/actions'; +import React, { useMemo, useState } from 'react'; import { ChooseActionBody } from '../choose-action-body'; import { ACTION_OPTIONS, type ActionOption } from './action-options'; -import { AutocompleteInput, Modal, NavigationButtons, Text, Divider, Option } from '@/reuseable-components'; +import { useActionFormData, useCreateAction } from '@/hooks/actions'; +import { AutocompleteInput, Modal, NavigationButtons, Text, Divider, FadeLoader } from '@/reuseable-components'; const DefineActionContainer = styled.section` height: 640px; @@ -28,50 +28,60 @@ const SubTitle = styled(Text)` line-height: 150%; `; +const Center = styled.div` + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +`; + interface AddActionModalProps { isModalOpen: boolean; handleCloseModal: () => void; } -interface ModalActionComponentProps { - onNext: () => void; -} - -const ModalActionComponent: React.FC = React.memo(({ onNext }) => { - const buttons = [ - { - label: 'DONE', - onClick: onNext, - variant: 'primary' as const, - }, - ]; - - return ; -}); - export const AddActionModal: React.FC = ({ isModalOpen, handleCloseModal }) => { - const submitRef = useRef<(() => void) | null>(null); + const { formData, handleFormChange, resetFormData, validateForm } = useActionFormData(); + const { createAction, loading } = useCreateAction({ onSuccess: handleClose }); const [selectedItem, setSelectedItem] = useState(null); - const { formData, handleFormChange, resetFormData } = useActionFormData(); - const handleNext = () => { - if (submitRef.current) { - handleCloseModal(); - } + const isFormOk = useMemo(() => !!selectedItem && validateForm(), [selectedItem, formData]); + + const handleSubmit = async () => { + createAction(formData); }; - const handleClose = () => { - handleCloseModal(); + function handleClose() { + resetFormData(); setSelectedItem(null); - }; + handleCloseModal(); + } - const handleSelect = (item: Option) => { + const handleSelect = (item: ActionOption) => { resetFormData(); + handleFormChange('type', item.type); setSelectedItem(item); }; return ( - } header={{ title: 'Add Action' }} onClose={handleClose}> + + } + > {'Define Action'} @@ -87,7 +97,14 @@ export const AddActionModal: React.FC = ({ isModalOpen, han {!!selectedItem?.type ? ( - + + {loading ? ( +
+ +
+ ) : ( + + )}
) : null}
diff --git a/frontend/webapp/graphql/mutations/action.ts b/frontend/webapp/graphql/mutations/action.ts new file mode 100644 index 000000000..aee5f41e3 --- /dev/null +++ b/frontend/webapp/graphql/mutations/action.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const CREATE_ACTION = gql` + mutation CreateAction($action: ActionInput!) { + createAction(action: $action) { + id + } + } +`; diff --git a/frontend/webapp/hooks/actions/index.ts b/frontend/webapp/hooks/actions/index.ts index 373528190..ad92ad62b 100644 --- a/frontend/webapp/hooks/actions/index.ts +++ b/frontend/webapp/hooks/actions/index.ts @@ -1,4 +1,5 @@ -export * from './useActionState'; +export * from './useActionFormData'; export * from './useActions'; +export * from './useActionState'; +export * from './useCreateAction'; export * from './useGetActions'; -export * from './useActionFormData'; diff --git a/frontend/webapp/hooks/actions/useActionFormData.ts b/frontend/webapp/hooks/actions/useActionFormData.ts index ea0f083f0..ef4b525d7 100644 --- a/frontend/webapp/hooks/actions/useActionFormData.ts +++ b/frontend/webapp/hooks/actions/useActionFormData.ts @@ -1,20 +1,11 @@ import { useState } from 'react'; -import { SignalUppercase } from '@/utils'; - -export type ActionFormData = { - type: string; - name: string; - notes: string; - disable: boolean; - signals: SignalUppercase[]; - details: string; -}; +import { ActionInput } from '@/types'; -const INITIAL: ActionFormData = { +const INITIAL: ActionInput = { type: '', name: '', notes: '', - disable: true, + disable: false, signals: [], details: '', }; @@ -22,10 +13,6 @@ const INITIAL: ActionFormData = { export function useActionFormData() { const [formData, setFormData] = useState({ ...INITIAL }); - const resetFormData = () => { - setFormData({ ...INITIAL }); - }; - const handleFormChange = (key: keyof typeof INITIAL, val: any) => { setFormData((prev) => ({ ...prev, @@ -33,9 +20,33 @@ export function useActionFormData() { })); }; + const resetFormData = () => { + setFormData({ ...INITIAL }); + }; + + const validateForm = () => { + let ok = true; + + Object.entries(formData).forEach(([k, v]) => { + switch (k) { + case 'type': + case 'signals': + case 'details': + if (Array.isArray(v) ? !v.length : !v) ok = false; + break; + + default: + break; + } + }); + + return ok; + }; + return { formData, handleFormChange, resetFormData, + validateForm, }; } diff --git a/frontend/webapp/hooks/actions/useCreateAction.ts b/frontend/webapp/hooks/actions/useCreateAction.ts new file mode 100644 index 000000000..f8449da78 --- /dev/null +++ b/frontend/webapp/hooks/actions/useCreateAction.ts @@ -0,0 +1,30 @@ +import { useNotify } from '../useNotify'; +import type { ActionInput } from '@/types'; +import { useMutation } from '@apollo/client'; +import { CREATE_ACTION } from '@/graphql/mutations/action'; +import { useComputePlatform } from '../compute-platform'; + +export const useCreateAction = ({ onSuccess }: { onSuccess?: () => void }) => { + const [createAction, { loading }] = useMutation(CREATE_ACTION, { + onError: (error) => + notify({ + message: error.message, + title: 'Create Action Error', + type: 'error', + target: 'notification', + crdType: 'notification', + }), + onCompleted: () => { + refetch(); + onSuccess && onSuccess(); + }, + }); + + const { refetch } = useComputePlatform(); + const notify = useNotify(); + + return { + createAction: (action: ActionInput) => createAction({ variables: { action } }), + loading, + }; +}; diff --git a/frontend/webapp/reuseable-components/monitoring-checkboxes/index.tsx b/frontend/webapp/reuseable-components/monitoring-checkboxes/index.tsx index 3b4515955..84d3188aa 100644 --- a/frontend/webapp/reuseable-components/monitoring-checkboxes/index.tsx +++ b/frontend/webapp/reuseable-components/monitoring-checkboxes/index.tsx @@ -6,8 +6,8 @@ import { MONITORING_OPTIONS, SignalLowercase, SignalUppercase } from '@/utils'; interface MonitoringCheckboxesProps { isVertical?: boolean; - allowedSignals?: (SignalUppercase | SignalLowercase)[]; - selectedSignals: (SignalUppercase | SignalLowercase)[]; + allowedSignals?: SignalUppercase[]; + selectedSignals: SignalUppercase[]; setSelectedSignals: (value: SignalUppercase[]) => void; } @@ -22,45 +22,38 @@ const TextWrapper = styled.div` `; const monitors = MONITORING_OPTIONS; -const initialStatuses: Record = { - logs: false, - metrics: false, - traces: false, + +const isAllowed = (type: SignalLowercase, allowedSignals: MonitoringCheckboxesProps['allowedSignals']) => { + return !allowedSignals?.length || !!allowedSignals?.find((str) => str === type.toUpperCase()); +}; + +const isSelected = (type: SignalLowercase, selectedSignals: MonitoringCheckboxesProps['selectedSignals']) => { + return !!selectedSignals?.find((str) => str === type.toUpperCase()); }; const MonitoringCheckboxes: React.FC = ({ isVertical, allowedSignals, selectedSignals, setSelectedSignals }) => { - const [signalStatuses, setSignalStatuses] = useState({ ...initialStatuses }); + const [isLastSelection, setIsLastSelection] = useState(false); useEffect(() => { - const payload = { ...initialStatuses }; + const payload: SignalUppercase[] = []; - selectedSignals.forEach((str) => { - payload[str.toLowerCase()] = true; + monitors.forEach(({ type }) => { + if (isAllowed(type, allowedSignals)) { + payload.push(type.toUpperCase() as SignalUppercase); + } }); - if (JSON.stringify(payload) !== JSON.stringify(signalStatuses)) { - setSignalStatuses(payload); - } - }, [selectedSignals]); - - const handleChange = (key: keyof typeof signalStatuses, value: boolean) => { - const selected: SignalUppercase[] = []; - - setSignalStatuses((prev) => { - const payload = { ...prev, [key]: value }; + setSelectedSignals(payload); + setIsLastSelection(payload.length === 1); + // eslint-disable-next-line + }, [allowedSignals]); - Object.entries(payload).forEach(([sig, bool]) => { - if (bool) selected.push(sig.toUpperCase() as SignalUppercase); - }); - - return payload; - }); - - setSelectedSignals(selected); - }; + const handleChange = (key: SignalLowercase, isAdd: boolean) => { + const keyUpper = key.toUpperCase() as SignalUppercase; + const payload = isAdd ? [...selectedSignals, keyUpper] : selectedSignals.filter((str) => str !== keyUpper); - const isDisabled = (item: (typeof MONITORING_OPTIONS)[0]) => { - return !!allowedSignals && !allowedSignals.find((str) => str.toLowerCase() === item.type); + setSelectedSignals(payload); + setIsLastSelection(payload.length === 1); }; return ( @@ -70,15 +63,22 @@ const MonitoringCheckboxes: React.FC = ({ isVertical, - {monitors.map((monitor) => ( - handleChange(monitor.type, value)} - disabled={isDisabled(monitor)} - /> - ))} + {monitors.map((monitor) => { + const allowed = isAllowed(monitor.type, allowedSignals); + const selected = isSelected(monitor.type, selectedSignals); + + if (!allowed) return null; + + return ( + handleChange(monitor.type, value)} + /> + ); + })} ); diff --git a/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts b/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts index 749030575..61db870e7 100644 --- a/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts +++ b/frontend/webapp/reuseable-components/nodes-data-flow/builder.ts @@ -1,12 +1,7 @@ import theme from '@/styles/theme'; import { Node, Edge } from 'react-flow-renderer'; import { getMainContainerLanguageLogo } from '@/utils/constants/programming-languages'; -import { - ActionData, - ActionItem, - ActualDestination, - K8sActualSource, -} from '@/types'; +import { ActionData, ActionItem, ActualDestination, K8sActualSource } from '@/types'; // Constants const NODE_HEIGHT = 80; @@ -16,13 +11,7 @@ const ACTION_ICON_PATH = '/icons/actions/'; const HEADER_ICON_PATH = '/icons/overview/'; // Helper to create a node -const createNode = ( - id: string, - type: string, - x: number, - y: number, - data: Record -): Node => ({ +const createNode = (id: string, type: string, x: number, y: number, data: Record): Node => ({ id, type, position: { x, y }, @@ -30,12 +19,7 @@ const createNode = ( }); // Helper to create an edge -const createEdge = ( - id: string, - source: string, - target: string, - animated = true -): Edge => ({ +const createEdge = (id: string, source: string, target: string, animated = true): Edge => ({ id, source, target, @@ -45,9 +29,7 @@ const createEdge = ( // Extract the monitors from exported signals const extractMonitors = (exportedSignals: Record) => - Object.keys(exportedSignals).filter( - (signal) => exportedSignals[signal] === true - ); + Object.keys(exportedSignals).filter((signal) => exportedSignals[signal] === true); export const buildNodesAndEdges = ({ sources, @@ -75,26 +57,18 @@ export const buildNodesAndEdges = ({ tagValue: sources.length, }), ...sources.map((source, index) => - createNode( - `source-${index}`, - 'base', - leftColumnX, - NODE_HEIGHT * (index + 1), - { - type: 'source', - title: - source.name + - (source.reportedName ? ` (${source.reportedName})` : ''), - subTitle: source.kind, - imageUri: getMainContainerLanguageLogo(source), - status: 'healthy', - id: { - kind: source.kind, - name: source.name, - namespace: source.namespace, - }, - } - ) + createNode(`source-${index}`, 'base', leftColumnX, NODE_HEIGHT * (index + 1), { + type: 'source', + title: source.name + (source.reportedName ? ` (${source.reportedName})` : ''), + subTitle: source.kind, + imageUri: getMainContainerLanguageLogo(source), + status: 'healthy', + id: { + kind: source.kind, + name: source.name, + namespace: source.namespace, + }, + }) ), ]; @@ -106,21 +80,15 @@ export const buildNodesAndEdges = ({ tagValue: destinations.length, }), ...destinations.map((destination, index) => - createNode( - `destination-${index}`, - 'base', - rightColumnX, - NODE_HEIGHT * (index + 1), - { - type: 'destination', - title: destination.name, - subTitle: destination.destinationType.displayName, - imageUri: destination.destinationType.imageUrl, - status: 'healthy', - monitors: extractMonitors(destination.exportedSignals), - id: destination.id, - } - ) + createNode(`destination-${index}`, 'base', rightColumnX, NODE_HEIGHT * (index + 1), { + type: 'destination', + title: destination.name, + subTitle: destination.destinationType.displayName, + imageUri: destination.destinationType.imageUrl, + status: 'healthy', + monitors: extractMonitors(destination.exportedSignals), + id: destination.id, + }) ), ]; @@ -132,45 +100,32 @@ export const buildNodesAndEdges = ({ tagValue: actions.length, }), ...actions.map((action, index) => { - const actionSpec: ActionItem = - typeof action.spec === 'string' - ? JSON.parse(action.spec) - : (action.spec as ActionItem); - - return createNode( - `action-${index}`, - 'base', - centerColumnX, - NODE_HEIGHT * (index + 1), - { - type: 'action', - title: actionSpec.actionName, - subTitle: action.type, - imageUri: `${ACTION_ICON_PATH}${action.type.toLowerCase()}.svg`, - monitors: actionSpec.signals, - status: 'healthy', - id: action.id, - } - ); + const actionSpec: ActionItem = typeof action.spec === 'string' ? JSON.parse(action.spec) : (action.spec as ActionItem); + const typeLowerCased = action.type.toLowerCase(); + const isSampler = typeLowerCased.indexOf('sampler') === action.type.length - 7; + + return createNode(`action-${index}`, 'base', centerColumnX, NODE_HEIGHT * (index + 1), { + type: 'action', + title: actionSpec.actionName, + subTitle: action.type, + imageUri: `${ACTION_ICON_PATH}${isSampler ? 'sampler' : typeLowerCased}.svg`, + monitors: actionSpec.signals, + status: 'healthy', + id: action.id, + }); }), ]; if (actionsNode.length === 1) { actionsNode.push( - createNode( - `action-0`, - 'addAction', - centerColumnX, - NODE_HEIGHT * (actions.length + 1), - { - type: 'addAction', - title: 'ADD ACTION', - subTitle: '', - imageUri: `${ACTION_ICON_PATH}add-action.svg`, - status: 'healthy', - onClick: () => console.log('Add Action'), - } - ) + createNode(`action-0`, 'addAction', centerColumnX, NODE_HEIGHT * (actions.length + 1), { + type: 'addAction', + title: 'ADD ACTION', + subTitle: '', + imageUri: `${ACTION_ICON_PATH}add-action.svg`, + status: 'healthy', + onClick: () => console.log('Add Action'), + }) ); } @@ -182,36 +137,19 @@ export const buildNodesAndEdges = ({ // Connect sources to actions const sourceToActionEdges: Edge[] = sources.map((_, sourceIndex) => { - const actionIndex = - actionsNode.length === 2 ? 0 : sourceIndex % actions.length; - return createEdge( - `source-${sourceIndex}-to-action-${actionIndex}`, - `source-${sourceIndex}`, - `action-${actionIndex}`, - false - ); + const actionIndex = actionsNode.length === 2 ? 0 : sourceIndex % actions.length; + return createEdge(`source-${sourceIndex}-to-action-${actionIndex}`, `source-${sourceIndex}`, `action-${actionIndex}`, false); }); // Connect actions to destinations const actionToDestinationEdges: Edge[] = actions.flatMap((_, actionIndex) => { return destinations.map((_, destinationIndex) => - createEdge( - `action-${actionIndex}-to-destination-${destinationIndex}`, - `action-${actionIndex}`, - `destination-${destinationIndex}` - ) + createEdge(`action-${actionIndex}-to-destination-${destinationIndex}`, `action-${actionIndex}`, `destination-${destinationIndex}`) ); }); if (actions.length === 0) { for (let i = 0; i < destinations.length; i++) { - actionToDestinationEdges.push( - createEdge( - `action-0-to-destination-${i}`, - `action-0`, - `destination-${i}`, - false - ) - ); + actionToDestinationEdges.push(createEdge(`action-0-to-destination-${i}`, `action-0`, `destination-${i}`, false)); } } diff --git a/frontend/webapp/types/actions.ts b/frontend/webapp/types/actions.ts index 37ba58dbe..420d29b46 100644 --- a/frontend/webapp/types/actions.ts +++ b/frontend/webapp/types/actions.ts @@ -1,3 +1,5 @@ +import { type SignalUppercase } from '@/utils'; + export enum ActionsType { ADD_CLUSTER_INFO = 'AddClusterInfo', DELETE_ATTRIBUTES = 'DeleteAttribute', @@ -52,3 +54,12 @@ export interface ActionState { disabled: boolean; type: string; } + +export type ActionInput = { + type: string; + name: string; + notes: string; + disable: boolean; + signals: SignalUppercase[]; + details: string; +};