From 18fe5f38d6c745f55bcac494761ae92cff6aa243 Mon Sep 17 00:00:00 2001 From: Bat Zion Rotman Date: Sun, 12 Mar 2023 23:40:27 +0200 Subject: [PATCH] Enabled selecting nodes by role --- http-server.sh | 9 ++ ...ugin__node-remediation-console-plugin.json | 21 ++- package.json | 7 +- src/apis/useSelectedNodes.tsx | 22 +++ .../LabelSelectionField.tsx | 152 ++++++++++++++++++ .../formView/nodeSelectionField/NodeList.tsx | 83 ++++------ .../nodeSelectionField/NodeSelectionField.tsx | 50 +----- .../UnhealthyConditionsField.tsx | 2 +- src/components/shared/MultiSelectField.tsx | 49 +++--- src/copiedFromConsole/nodes/NodeStatus.tsx | 28 +++- src/copiedFromConsole/nodes/node.ts | 15 -- src/copiedFromConsole/selectors/node.ts | 70 -------- src/data/nodeRoles.ts | 70 ++++++++ src/data/validationSchema.ts | 1 + yarn.lock | 99 +++++++++++- 15 files changed, 459 insertions(+), 219 deletions(-) create mode 100755 http-server.sh create mode 100644 src/apis/useSelectedNodes.tsx create mode 100644 src/components/editor/formView/nodeSelectionField/LabelSelectionField.tsx delete mode 100644 src/copiedFromConsole/nodes/node.ts delete mode 100644 src/copiedFromConsole/selectors/node.ts create mode 100644 src/data/nodeRoles.ts diff --git a/http-server.sh b/http-server.sh new file mode 100755 index 0000000..12bb9c7 --- /dev/null +++ b/http-server.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -u + +PUBLIC_PATH="$1" +shift +SERVER_OPTS="$@" + +./node_modules/.bin/http-server $PUBLIC_PATH -p 9001 -c-1 --cors $SERVER_OPTS diff --git a/locales/en/plugin__node-remediation-console-plugin.json b/locales/en/plugin__node-remediation-console-plugin.json index 13e18c3..2546f39 100644 --- a/locales/en/plugin__node-remediation-console-plugin.json +++ b/locales/en/plugin__node-remediation-console-plugin.json @@ -37,17 +37,23 @@ "The minimum percentage or number of nodes that has to be healthy for the remediation to start.": "The minimum percentage or number of nodes that has to be healthy for the remediation to start.", "Note: Some fields may not be represented in this form view. Please select \"YAML view\" for full control": "Note: Some fields may not be represented in this form view. Please select \"YAML view\" for full control", "A unique name for the NodeHealthCheck": "A unique name for the NodeHealthCheck", + "Role": "Role", + "Control plane": "Control plane", + "Worker": "Worker", + "Label": "Label", + "Nodes selection": "Nodes selection", + "Select the labels that will be used to find unhealthy nodes for remediation. The nodes must satisfy all selected labels.": "Select the labels that will be used to find unhealthy nodes for remediation. The nodes must satisfy all selected labels.", + "No nodes were selected, use filter to select nodes": "No nodes were selected, use filter to select nodes", "No nodes match the selected labels": "No nodes match the selected labels", "Failed to fetch nodes": "Failed to fetch nodes", "nodes": "nodes", - "Nodes selection": "Nodes selection", - "Use labels to select the nodes you want to remediate. Leaving this field empty will select all nodes of the cluster.": "Use labels to select the nodes you want to remediate. Leaving this field empty will select all nodes of the cluster.", "Self node remediation template uses the remediation strategy 'Resource Deletion'.": "Self node remediation template uses the remediation strategy 'Resource Deletion'.", "Self node remediation is disabled because its templates can't be found. Please reinstall the Self Node Remediation Operator.": "Self node remediation is disabled because its templates can't be found. Please reinstall the Self Node Remediation Operator.", "Other": "Other", "Use custom type": "Use custom type", - "Cancel": "Cancel", "Name of the custom type": "Name of the custom type", + "Create": "Create", + "Cancel": "Cancel", "Specifies the timeout duration for a node condition. If a condition is met for the duration of the timeout, the node remediation will occur.": "Specifies the timeout duration for a node condition. If a condition is met for the duration of the timeout, the node remediation will occur.", "Expects a string of decimal numbers each with optional fraction and a unit suffix, eg \"300ms\", \"1.5h\" or \"2h45m\". Valid time units are \"ns\", \"us\" (or \"µs\"), \"ms\", \"s\", \"m\", \"h\".": "Expects a string of decimal numbers each with optional fraction and a unit suffix, eg \"300ms\", \"1.5h\" or \"2h45m\". Valid time units are \"ns\", \"us\" (or \"µs\"), \"ms\", \"s\", \"m\", \"h\".", "Ready": "Ready", @@ -55,14 +61,13 @@ "Memory pressure": "Memory pressure", "PID pressure": "PID pressure", "Network unavailable": "Network unavailable", - "Nodes that meet any of these conditions for a certain amount of time will be remediated.": "Nodes that meet any of these conditions for a certain amount of time will be remediated.", + "Nodes that meet any of these conditions for the given duration will be remediated.": "Nodes that meet any of these conditions for the given duration will be remediated.", "Create NodeHealthCheck": "Create NodeHealthCheck", "Failed to retrive OCP version for LearnMore link: ": "Failed to retrive OCP version for LearnMore link: ", "Learn more": "Learn more", "NodeHealthChecks define a set of criteria and thresholds to determine the health of a node.": "NodeHealthChecks define a set of criteria and thresholds to determine the health of a node.", "This object has been updated.": "This object has been updated.", "Click reload to see the new version.": "Click reload to see the new version.", - "Create": "Create", "Save": "Save", "Failed to parse NodeHealthCheck YAML": "Failed to parse NodeHealthCheck YAML", "NodeHealthChecks are disabled": "NodeHealthChecks are disabled", @@ -136,8 +141,12 @@ "{{labels}} content is not available in the catalog at this time due to loading failures.": "{{labels}} content is not available in the catalog at this time due to loading failures.", "Timed out fetching new data. The data below is stale.": "Timed out fetching new data. The data below is stale.", "Self node remediation": "Self node remediation", - "Expected value matches regular expression:": "Expected value matches regular expression:", + "1-{{max}} characters": "1-{{max}} characters", + "Must be unique": "Must be unique", + "Use lowercase alphanumeric characters, dot (.) or hyphen (-)": "Use lowercase alphanumeric characters, dot (.) or hyphen (-)", + "Must start and end with an lowercase alphanumeric character": "Must start and end with an lowercase alphanumeric character", "Expected value is a percentage or a number. For example: 25 or 70%": "Expected value is a percentage or a number. For example: 25 or 70%", + "Expected value matches regular expression:": "Expected value matches regular expression:", "Name must be unique. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: http://kubernetes.io/docs/user-guide/identifiers#names": "Name must be unique. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: http://kubernetes.io/docs/user-guide/identifiers#names", "Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: http://kubernetes.io/docs/user-guide/annotations": "Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: http://kubernetes.io/docs/user-guide/annotations", "Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: http://kubernetes.io/docs/user-guide/labels": "Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: http://kubernetes.io/docs/user-guide/labels", diff --git a/package.json b/package.json index e5390de..97948a3 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "ts-node": "ts-node -O '{\"module\":\"commonjs\"}'", "lint": "eslint ./src --fix && stylelint \"src/**/*.css\" --allow-empty-input --fix", "open-cypress": "yarn cypress open --e2e --browser=chrome", - "test": "yarn cypress run --e2e --browser=chrome" + "test": "yarn cypress run --e2e --browser=chrome", + "http-server": "./http-server.sh dist" }, "devDependencies": { "@cypress/webpack-preprocessor": "^5.12.2", @@ -31,7 +32,9 @@ "@types/react": "17.0.37", "@types/react-helmet": "6.1.4", "@types/react-router-dom": "5.3.2", + "@types/semver": "^7.3.12", "@types/yup": "^0.29.14", + "@types/lodash-es": "^4.17.6", "@typescript-eslint/eslint-plugin": "^5.29.0", "@typescript-eslint/parser": "^5.29.0", "copy-webpack-plugin": "6.4.1", @@ -62,7 +65,7 @@ "webpack": "^5.74.0", "webpack-cli": "^4.9.2", "webpack-dev-server": "^4.7.4", - "@types/semver": "^7.3.12" + "http-server": "^14.1.1" }, "consolePlugin": { "name": "node-remediation-console-plugin", diff --git a/src/apis/useSelectedNodes.tsx b/src/apis/useSelectedNodes.tsx new file mode 100644 index 0000000..d747efd --- /dev/null +++ b/src/apis/useSelectedNodes.tsx @@ -0,0 +1,22 @@ +import { + useK8sWatchResource, + WatchK8sResource, +} from "@openshift-console/dynamic-plugin-sdk"; +import { selectorFromStringArray } from "copiedFromConsole/module/selector"; +import { NodeKind } from "copiedFromConsole/types/node"; +import { nodeKind } from "data/model"; + +const useSelectedNodes = (selectedLabels: string[]) => { + const resource: WatchK8sResource | null = + selectedLabels.length > 0 + ? { + groupVersionKind: nodeKind, + selector: selectorFromStringArray(selectedLabels), + isList: true, + namespaced: false, + } + : null; + return useK8sWatchResource(resource); +}; + +export default useSelectedNodes; diff --git a/src/components/editor/formView/nodeSelectionField/LabelSelectionField.tsx b/src/components/editor/formView/nodeSelectionField/LabelSelectionField.tsx new file mode 100644 index 0000000..d4f0390 --- /dev/null +++ b/src/components/editor/formView/nodeSelectionField/LabelSelectionField.tsx @@ -0,0 +1,152 @@ +import { + Chip, + ChipGroup, + Flex, + FlexItem, + Stack, + StackItem, + SelectGroup, + SelectOption, +} from "@patternfly/react-core"; +import { NodeKind } from "copiedFromConsole/types/node"; +import { useNodeHealthCheckTranslation } from "localization/useNodeHealthCheckTranslation"; +import * as React from "react"; +import { uniq, flatten } from "lodash-es"; +import useDeepCompareMemoize from "hooks/useDeepCompareMemoize"; +import { useField } from "formik"; +import MultiSelectField from "components/shared/MultiSelectField"; +import { intersection } from "lodash-es"; +import { ClusterRoleLabels, getClusterRoleLabels, Role } from "data/nodeRoles"; + +const stringifyNodeLabels = (node: NodeKind): string[] => { + if (!node.metadata?.labels) { + return []; + } + return Object.entries(node.metadata.labels).map(([key, value]) => + value ? `${key}=${value}` : key + ); +}; + +const getAllNodesLabels = (allNodes: NodeKind[]): string[] => + uniq(flatten(allNodes.map((node) => stringifyNodeLabels(node)))).sort(); + +const LabelSelectionField = ({ + allNodes, + isLoading, + fieldName, +}: { + isLoading: boolean; + allNodes: NodeKind[]; + fieldName: string; +}) => { + const { t } = useNodeHealthCheckTranslation(); + const [field, , { setValue }] = useField(fieldName); + + const [roleLabels, setRoleLabels] = React.useState({}); + const [selectGroups, setSelectGroups] = React.useState([]); + const memoValue = useDeepCompareMemoize(field.value); + const [allLabels, setAllLabels] = React.useState([]); + + React.useEffect(() => { + if (!isLoading) { + let _options = uniq( + flatten(allNodes.map((node) => stringifyNodeLabels(node))) + ); + //add value to options, needed for complex match expressions or labels that aren't currently on the nodes + //include previous options to not remove original match expressions + _options = uniq([...memoValue, ...allLabels, ..._options]).sort(); + const _selectGroups = []; + const _roleLabels = getClusterRoleLabels(allNodes); + if (Object.keys(_roleLabels).length === 2) { + _selectGroups.push( + + + {t("Control plane")} + + + {t("Worker")} + + + ); + } + _selectGroups.push( + + {getAllNodesLabels(allNodes).map((option) => ( + {option} + ))} + + ); + setSelectGroups(_selectGroups); + setRoleLabels(_roleLabels); + setAllLabels(_options); + } + }, [memoValue, isLoading]); // doesn't respond to allNodes, it can change every second + + const getSelectedRoleLabels = () => + intersection(field.value, Object.values(roleLabels)); + + const onDeleteLabel = (label: string) => { + setValue(field.value.filter((curLabel) => curLabel !== label)); + }; + + return ( + + + + + {!isLoading && ( + + + {getSelectedRoleLabels().length > 0 && ( + + + {getSelectedRoleLabels().map((label) => ( + onDeleteLabel(label)}> + {label === roleLabels[Role.CONTROL_PLANE] + ? t("Control plane") + : t("Worker")} + + ))} + + + )} + + + {field.value.map((label) => ( + onDeleteLabel(label)}> + {label} + + ))} + + + + + )} + + ); +}; + +export default LabelSelectionField; diff --git a/src/components/editor/formView/nodeSelectionField/NodeList.tsx b/src/components/editor/formView/nodeSelectionField/NodeList.tsx index ba18110..d528d3f 100644 --- a/src/components/editor/formView/nodeSelectionField/NodeList.tsx +++ b/src/components/editor/formView/nodeSelectionField/NodeList.tsx @@ -1,24 +1,21 @@ import { - k8sList, ResourceLink, RowProps, TableColumn, TableData, - useK8sModel, VirtualizedTable, } from "@openshift-console/dynamic-plugin-sdk"; -import { getNodeRolesText } from "copiedFromConsole/selectors/node"; import { NodeKind } from "copiedFromConsole/types/node"; import { nodeKind } from "data/model"; import * as React from "react"; -import NodeStatus from "copiedFromConsole/nodes/NodeStatus"; +import NodeStatus, { nodeStatus } from "copiedFromConsole/nodes/NodeStatus"; import { sortable, SortByDirection } from "@patternfly/react-table"; -import { nodeStatus } from "copiedFromConsole/nodes/node"; + import { useField } from "formik"; -import { selectorFromStringArray } from "copiedFromConsole/module/selector"; -import { useDeepCompareMemoize } from "copiedFromConsole/hooks/deep-compare-memoize"; import { EmptyState, Title } from "@patternfly/react-core"; import { useNodeHealthCheckTranslation } from "localization/useNodeHealthCheckTranslation"; +import { getNodeRolesText } from "data/nodeRoles"; +import useSelectedNodes from "apis/useSelectedNodes"; const sortByStatus = (nodes: NodeKind[], sortDirection: SortByDirection) => { return nodes.sort((node1: NodeKind, node2: NodeKind) => { @@ -72,7 +69,7 @@ const NodeRow: React.FC> = ({ obj, activeColumnIDs }) => { /> - + {getNodeRolesText(obj)} @@ -81,61 +78,35 @@ const NodeRow: React.FC> = ({ obj, activeColumnIDs }) => { ); }; -const EmptyMsg = () => { - const { t } = useNodeHealthCheckTranslation(); - return ( - - - {t("No nodes match the selected labels")} - - - ); +const getEmptyMsg = (selectedLabels: string[]) => { + const component = () => { + const { t } = useNodeHealthCheckTranslation(); + return ( + + + {selectedLabels.length === 0 + ? t("No nodes were selected, use filter to select nodes") + : t("No nodes match the selected labels")} + + + ); + }; + return component; }; -const NodeList: React.FC<{ - allNodes: NodeKind[]; - fieldName: string; - allNodesLoaded: boolean; -}> = ({ allNodes, fieldName, allNodesLoaded }) => { +const NodeList = ({ fieldName }: { fieldName: string }) => { const [{ value }] = useField(fieldName); - const [nodeModel, modelLoading] = useK8sModel(nodeKind); - const [selectedNodes, setSelectedNodes] = React.useState([]); - const [loading, setLoading] = React.useState(true); - const [loadError, setLoadError] = React.useState(); - const memoValue = useDeepCompareMemoize(value); - React.useEffect(() => { - if (!memoValue || !allNodesLoaded || !nodeModel) { - return; - } - if (memoValue.length === 0) { - setLoading(false); - setSelectedNodes(allNodes); - return; - } - setLoadError(undefined); - setLoading(true); - k8sList({ - model: nodeModel, - queryParams: { labelSelector: selectorFromStringArray(memoValue) }, - }) - .then((nodeList) => { - setLoading(false); - setSelectedNodes(nodeList as NodeKind[]); - }) - .catch((err) => { - setLoadError(err); - setLoading(false); - }); - }, [memoValue, allNodesLoaded, nodeModel]); // doesn't respond to allNodes, it changes every second + const [selectedNodes, loaded, error] = useSelectedNodes(value); + console.log("selectedNodes", selectedNodes); return ( - data={selectedNodes} - unfilteredData={selectedNodes} - loaded={!modelLoading && !loading && allNodesLoaded} - loadError={loadError} + data={selectedNodes || []} + unfilteredData={selectedNodes || []} + loaded={loaded} + loadError={error} columns={columns} Row={NodeRow} - EmptyMsg={EmptyMsg} + EmptyMsg={getEmptyMsg(value)} /> ); }; diff --git a/src/components/editor/formView/nodeSelectionField/NodeSelectionField.tsx b/src/components/editor/formView/nodeSelectionField/NodeSelectionField.tsx index 857277a..6b058db 100644 --- a/src/components/editor/formView/nodeSelectionField/NodeSelectionField.tsx +++ b/src/components/editor/formView/nodeSelectionField/NodeSelectionField.tsx @@ -1,50 +1,23 @@ -import { useField } from "formik"; import * as React from "react"; import { NodeKind } from "copiedFromConsole/types/node"; import NodeList from "./NodeList"; -import MultiSelectField from "components/shared/MultiSelectField"; -import { uniq, flatten } from "lodash"; import { useNodeHealthCheckTranslation } from "localization/useNodeHealthCheckTranslation"; -import useDeepCompareMemoize from "hooks/useDeepCompareMemoize"; import { nodeKind } from "data/model"; import { useK8sWatchResource } from "@openshift-console/dynamic-plugin-sdk"; import { LoadError } from "copiedFromConsole/utils/status-box"; - -const stringifyNodeLabels = (node: NodeKind): string[] => { - if (!node.metadata?.labels) { - return []; - } - return Object.entries(node.metadata.labels).map(([key, value]) => - value ? `${key}=${value}` : key - ); -}; +import LabelSelectionField from "./LabelSelectionField"; const NodeSelectionField: React.FC<{ fieldName: string; }> = ({ fieldName }) => { const { t } = useNodeHealthCheckTranslation(); - const [{ value }] = useField(fieldName); - const [options, setOptions] = React.useState(); const [allNodes, loaded, loadError] = useK8sWatchResource({ groupVersionKind: nodeKind, isList: true, namespaced: false, }); - const memoValue = useDeepCompareMemoize(value); - React.useEffect(() => { - const curOptions = options || []; - if (loaded && !loadError) { - let _options = uniq( - flatten(allNodes.map((node) => stringifyNodeLabels(node))) - ); - //add value to options, needed for complex match expressions or labels that aren't currently on the nodes - //include previous options to not remove original match expressions - _options = uniq([...memoValue, ...curOptions, ..._options]).sort(); - setOptions(_options); - } - }, [memoValue, loaded, loadError]); // doesn't respond to allNodes, it changes every second if (loadError) { return ( - +
- +
); diff --git a/src/components/editor/formView/unhealthyConditionsField/UnhealthyConditionsField.tsx b/src/components/editor/formView/unhealthyConditionsField/UnhealthyConditionsField.tsx index bba9e42..9c03c98 100644 --- a/src/components/editor/formView/unhealthyConditionsField/UnhealthyConditionsField.tsx +++ b/src/components/editor/formView/unhealthyConditionsField/UnhealthyConditionsField.tsx @@ -120,7 +120,7 @@ const UnhealthyConditionsField = ({ fieldName }: FormViewFieldProps) => { Unhealthy conditions {t( - "Nodes that meet any of these conditions for a certain amount of time will be remediated." + "Nodes that meet any of these conditions for the given duration will be remediated." )} diff --git a/src/components/shared/MultiSelectField.tsx b/src/components/shared/MultiSelectField.tsx index 0cb58c2..b457f8f 100644 --- a/src/components/shared/MultiSelectField.tsx +++ b/src/components/shared/MultiSelectField.tsx @@ -3,20 +3,20 @@ import { useField } from "formik"; import { FormGroup, Select, - SelectOption, SelectProps, SelectVariant, } from "@patternfly/react-core"; import { getFieldId } from "copiedFromConsole/formik-fields/field-utils"; import { FieldProps } from "copiedFromConsole/formik-fields/field-types"; -import * as fuzzy from "fuzzysearch"; +import fuzzy from "fuzzysearch"; export interface MultiSelectFieldProps extends FieldProps { - options: string[]; + options: JSX.Element[]; placeholderText?: string; onChange?: (val: string[]) => void; getHelperText?: (value: string) => React.ReactNode | undefined; enableClear: boolean; isLoading: boolean; + isRequired: boolean; } // Field value is a string[] @@ -31,6 +31,7 @@ const MultiSelectField: React.FC = ({ labelIcon, enableClear, isLoading, + isRequired, ...props }) => { const [isOpen, setOpen] = React.useState(false); @@ -61,13 +62,23 @@ const MultiSelectField: React.FC = ({ setValue(newValue); }; - const children = options - .filter((option) => !(field.value || []).includes(option)) - .map((option) => ( - - {option} - - )); + const onFilter = (_, textInput) => { + if (textInput === "") { + return options; + } else { + let filteredGroups = options + .map((group) => { + let filteredGroup = React.cloneElement(group, { + children: group.props.children.filter((item) => { + return fuzzy(textInput, item.props.value); + }), + }); + if (filteredGroup.props.children.length > 0) return filteredGroup; + }) + .filter((newGroup) => newGroup); + return filteredGroups; + } + }; return ( = ({ helperText={hText} helperTextInvalid={errorMessage} validated={isValid ? "default" : "error"} - isRequired={required} + isRequired={isRequired} labelIcon={labelIcon} data-test={`multi-select-${label}`} > @@ -84,26 +95,24 @@ const MultiSelectField: React.FC = ({ {...field} {...props} id={fieldId} - variant={SelectVariant.typeaheadMulti} + variant={SelectVariant.checkbox} typeAheadAriaLabel="Select a label" validated={isValid ? "default" : "error"} aria-describedby={`${fieldId}-helper`} isCreatable={false} - placeholderText={placeholderText} + placeholderText="Filter by label" isOpen={isOpen} onToggle={onToggle} onSelect={onSelect} selections={field.value} onClear={enableClear ? onClearSelection : null} loadingVariant={isLoading ? "spinner" : undefined} - onFilter={(e, val) => { - if (!val || val === "") { - return children; - } - return children.filter((child) => fuzzy(val, child.props.value)); - }} + isGrouped + hasInlineFilter + onFilter={onFilter} + maxHeight={400} > - {children} + {options} ); diff --git a/src/copiedFromConsole/nodes/NodeStatus.tsx b/src/copiedFromConsole/nodes/NodeStatus.tsx index 7a5fd66..a8ee195 100644 --- a/src/copiedFromConsole/nodes/NodeStatus.tsx +++ b/src/copiedFromConsole/nodes/NodeStatus.tsx @@ -1,13 +1,35 @@ import * as React from "react"; -import { NodeKind } from "../types/node"; +import { NodeCondition, NodeKind } from "../types/node"; import Status from "../status/Status"; import { Condition } from "../console-app/queries"; -import { getNodeSecondaryStatus, nodeStatus } from "./node"; import NodeUnschedulableStatus from "./NodeUnschedulableStatus"; import SecondaryStatus from "../status/SecondaryStatus"; import { Button } from "@patternfly/react-core"; -import { startCase } from "lodash-es"; +import { startCase, get, find } from "lodash-es"; +import { useNodeHealthCheckTranslation } from "localization/useNodeHealthCheckTranslation"; + +const isNodeUnschedulable = (node: NodeKind): boolean => + get(node, "spec.unschedulable", false); + +const isNodeReady = (node: NodeKind): boolean => { + const conditions = get(node, "status.conditions", []); + const readyState = find(conditions, { type: "Ready" }) as NodeCondition; + + return readyState && readyState.status === "True"; +}; + +export const nodeStatus = (node: NodeKind) => + isNodeReady(node) ? "Ready" : "Not Ready"; + +const getNodeSecondaryStatus = (node: NodeKind): string[] => { + const { t } = useNodeHealthCheckTranslation(); + const states = []; + if (isNodeUnschedulable(node)) { + states.push(t("Scheduling disabled")); + } + return states; +}; const isMonitoredCondition = (condition: Condition): boolean => [ diff --git a/src/copiedFromConsole/nodes/node.ts b/src/copiedFromConsole/nodes/node.ts deleted file mode 100644 index 2709e06..0000000 --- a/src/copiedFromConsole/nodes/node.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useNodeHealthCheckTranslation } from "localization/useNodeHealthCheckTranslation"; -import { isNodeReady, isNodeUnschedulable } from "../selectors/node"; -import { NodeKind } from "../types/node"; - -export const nodeStatus = (node: NodeKind) => - isNodeReady(node) ? "Ready" : "Not Ready"; - -export const getNodeSecondaryStatus = (node: NodeKind): string[] => { - const { t } = useNodeHealthCheckTranslation(); - const states = []; - if (isNodeUnschedulable(node)) { - states.push(t("Scheduling disabled")); - } - return states; -}; diff --git a/src/copiedFromConsole/selectors/node.ts b/src/copiedFromConsole/selectors/node.ts deleted file mode 100644 index 24033c1..0000000 --- a/src/copiedFromConsole/selectors/node.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { NodeAddress, NodeCondition, NodeKind } from "../types/node"; -import { get, reduce, find } from "lodash-es"; -const NODE_ROLE_PREFIX = "node-role.kubernetes.io/"; - -export const getNodeRoles = (node: NodeKind): string[] => { - const labels = get(node, "metadata.labels"); - return reduce( - labels, - (acc: string[], v: string, k: string) => { - if (k.startsWith(NODE_ROLE_PREFIX)) { - acc.push(k.slice(NODE_ROLE_PREFIX.length)); - } - return acc; - }, - [] - ); -}; - -export const getNodeRole = (node: NodeKind): string => - getNodeRoles(node).includes("master") ? "master" : "worker"; - -export const getNodeRolesText = (node: NodeKind): string => { - return getNodeRoles(node).sort().join(", ") ?? "-"; -}; - -export const getNodeAddresses = (node: NodeKind): NodeAddress[] => - get(node, "status.addresses", []); - -type NodeMachineAndNamespace = { - name: string; - namespace: string; -}; -export const getNodeMachineNameAndNamespace = ( - node: NodeKind -): NodeMachineAndNamespace => { - const machine = get( - node, - 'metadata.annotations["machine.openshift.io/machine"]', - "/" - ); - const [namespace, name] = machine.split("/"); - return { namespace, name }; -}; - -export const getNodeMachineName = (node: NodeKind): string => - getNodeMachineNameAndNamespace(node).name; - -export const isNodeUnschedulable = (node: NodeKind): boolean => - get(node, "spec.unschedulable", false); - -export const isNodeReady = (node: NodeKind): boolean => { - const conditions = get(node, "status.conditions", []); - const readyState = find(conditions, { type: "Ready" }) as NodeCondition; - - return readyState && readyState.status === "True"; -}; - -export const getNodeCPUCapacity = (node: NodeKind): string => - get(node.status, "capacity.cpu"); - -export const getNodeAllocatableMemory = (node: NodeKind): string => - get(node.status, "allocatable.memory"); - -export const getNodeTaints = (node: NodeKind) => node?.spec?.taints; - -export const isWindowsNode = (node) => - node?.metadata?.labels?.["node.openshift.io/os_id"] === "Windows" || - node?.metadata?.labels?.["corev1.LabelOSStable"] === "windows"; - -export const getNodeWorkerLabel = () => NODE_ROLE_PREFIX + "worker"; diff --git a/src/data/nodeRoles.ts b/src/data/nodeRoles.ts new file mode 100644 index 0000000..23656a2 --- /dev/null +++ b/src/data/nodeRoles.ts @@ -0,0 +1,70 @@ +import { NodeKind } from "../copiedFromConsole/types/node"; +import { get, reduce, flatten, uniq } from "lodash-es"; + +const NODE_ROLE_PREFIX = "node-role.kubernetes.io/"; + +const getRoleLabel = (roleText: string) => `${NODE_ROLE_PREFIX}${roleText}`; +const masterLabel = getRoleLabel("master"); + +export enum Role { + WORKER = "worker", + CONTROL_PLANE = "control-plane", +} + +export type ClusterRoleLabels = { + [Role.CONTROL_PLANE]?: string; + [Role.WORKER]?: string; +}; + +export const getClusterRoleLabels = ( + allNodes: NodeKind[] +): ClusterRoleLabels => { + const res: ClusterRoleLabels = {}; + const masterLabel = getRoleLabel("master"); + const allLabels = flatten( + allNodes.map((node) => Object.keys(node.metadata?.labels || {})) + ); + for (const role of Object.values(Role)) { + const label = getRoleLabel(role); + if (allLabels.includes(label)) { + res[role] = label; + } + } + if (!res[Role.CONTROL_PLANE] && allLabels.includes(masterLabel)) { + res[Role.CONTROL_PLANE] = masterLabel; + } + return res; +}; + +export const getNodeRoles = (node: NodeKind): string[] => { + const labels = get(node, "metadata.labels"); + return reduce( + labels, + (acc: string[], v: string, label: string) => { + const role = label.slice(NODE_ROLE_PREFIX.length); + if (!role) { + return acc; + } + if (label === masterLabel) { + return [...acc, Role.CONTROL_PLANE]; + } + return [...acc, role]; + }, + [] + ); +}; +export const getClusterLabelOfRole = (allNodes: NodeKind[], role: Role) => { + const masterLabel = `${NODE_ROLE_PREFIX}/master`; + const allLabels = flatten( + allNodes.map((node) => Object.keys(node.metadata?.labels || {})) + ); + if (allLabels.includes(`${NODE_ROLE_PREFIX}/${role}`)) { + return `${NODE_ROLE_PREFIX}/${role}`; + } else if (allLabels.includes(masterLabel)) { + return masterLabel; + } +}; + +export const getNodeRolesText = (node: NodeKind): string => { + return uniq(getNodeRoles(node)).sort().join(", ") ?? "-"; +}; diff --git a/src/data/validationSchema.ts b/src/data/validationSchema.ts index 5609ccd..a7ccdea 100644 --- a/src/data/validationSchema.ts +++ b/src/data/validationSchema.ts @@ -81,6 +81,7 @@ const getFormDataSchema = (t: TFunction) => remediator: yup.object({ template: remediatorSchema, }), + nodeSelector: yup.array().of(yup.string()).required().min(1), }); export const getValidationSchema = (t: TFunction) => diff --git a/yarn.lock b/yarn.lock index 0bb1119..760e4ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -442,6 +442,18 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ== +"@types/lodash-es@^4.17.6": + version "4.17.6" + resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.6.tgz#c2ed4c8320ffa6f11b43eb89e9eaeec65966a0a0" + integrity sha512-R+zTeVUKDdfoRxpAryaQNRKk3105Rrgx2CFRClIgRGaqDTdjsm8h6IYA8ir584W3ePzkZfst5xIgDwYrlh9HLg== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.14.191" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.191.tgz#09511e7f7cba275acd8b419ddac8da9a6a79e2fa" + integrity sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ== + "@types/lodash@4.14.182", "@types/lodash@^4.14.175": version "4.14.182" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.182.tgz#05301a4d5e62963227eaafe0ce04dd77c54ea5c2" @@ -1202,6 +1214,13 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +basic-auth@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.1.tgz#b998279bf47ce38344b4f3cf916d4679bbf51e3a" + integrity sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg== + dependencies: + safe-buffer "5.1.2" + batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" @@ -1526,7 +1545,7 @@ chalk@^1.1.3: strip-ansi "^3.0.0" supports-color "^2.0.0" -chalk@^4.0.0, chalk@^4.1.0: +chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -1947,6 +1966,11 @@ core-util-is@^1.0.2, core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== +corser@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/corser/-/corser-2.0.1.tgz#8eda252ecaab5840dcd975ceb90d9370c819ff87" + integrity sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ== + cosmiconfig@^3.0.1, cosmiconfig@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-3.1.0.tgz#640a94bf9847f321800403cd273af60665c73397" @@ -3731,6 +3755,13 @@ hpack.js@^2.1.6: readable-stream "^2.0.1" wbuf "^1.1.0" +html-encoding-sniffer@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz#2cb1a8cf0db52414776e5b2a7a04d5dd98158de9" + integrity sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA== + dependencies: + whatwg-encoding "^2.0.0" + html-entities@^2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.2.tgz#760b404685cb1d794e4f4b744332e3b00dcfe488" @@ -3837,6 +3868,25 @@ http-proxy@^1.18.1: follow-redirects "^1.0.0" requires-port "^1.0.0" +http-server@^14.1.1: + version "14.1.1" + resolved "https://registry.yarnpkg.com/http-server/-/http-server-14.1.1.tgz#d60fbb37d7c2fdff0f0fbff0d0ee6670bd285e2e" + integrity sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A== + dependencies: + basic-auth "^2.0.1" + chalk "^4.1.2" + corser "^2.0.1" + he "^1.2.0" + html-encoding-sniffer "^3.0.0" + http-proxy "^1.18.1" + mime "^1.6.0" + minimist "^1.2.6" + opener "^1.5.1" + portfinder "^1.0.28" + secure-compare "3.0.1" + union "~0.5.0" + url-join "^4.0.1" + http-signature@~1.3.6: version "1.3.6" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.3.6.tgz#cb6fbfdf86d1c974f343be94e87f7fc128662cf9" @@ -3899,6 +3949,13 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" +iconv-lite@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + icss-utils@^5.0.0, icss-utils@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" @@ -5106,7 +5163,7 @@ mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.24: dependencies: mime-db "1.51.0" -mime@1.6.0: +mime@1.6.0, mime@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== @@ -5514,6 +5571,11 @@ open@^8.0.9: is-docker "^2.1.1" is-wsl "^2.2.0" +opener@^1.5.1: + version "1.5.2" + resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" + integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== + optionator@^0.9.1: version "0.9.1" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499" @@ -6192,6 +6254,13 @@ qs@6.7.0: resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== +qs@^6.4.0: + version "6.11.1" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.1.tgz#6c29dff97f0c0060765911ba65cbc9764186109f" + integrity sha512-0wsrzgTz/kAVIeuxSjnpGC56rzYtr6JT/2BwEvMaPhFIoYa1aGO8LbzuU1R0uUYQkLpWBTOj0l/CLAJB64J6nQ== + dependencies: + side-channel "^1.0.4" + qs@~6.5.2: version "6.5.3" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.3.tgz#3aeeffc91967ef6e35c0e488ef46fb296ab76aad" @@ -6839,7 +6908,7 @@ safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -6871,6 +6940,11 @@ schema-utils@^4.0.0: ajv-formats "^2.1.1" ajv-keywords "^5.0.0" +secure-compare@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/secure-compare/-/secure-compare-3.0.1.tgz#f1a0329b308b221fae37b9974f3d578d0ca999e3" + integrity sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw== + select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -8018,6 +8092,13 @@ unified@^6.0.0: vfile "^2.0.0" x-is-string "^0.1.0" +union@~0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/union/-/union-0.5.0.tgz#b2c11be84f60538537b846edb9ba266ba0090075" + integrity sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA== + dependencies: + qs "^6.4.0" + uniq@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/uniq/-/uniq-1.0.1.tgz#b31c5ae8254844a3a8281541ce2b04b865a734ff" @@ -8138,6 +8219,11 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +url-join@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/url-join/-/url-join-4.0.1.tgz#b642e21a2646808ffa178c4c5fda39844e12cde7" + integrity sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA== + url-parse-lax@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73" @@ -8447,6 +8533,13 @@ websocket-extensions@>=0.1.1: resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== +whatwg-encoding@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz#e7635f597fd87020858626805a2729fa7698ac53" + integrity sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg== + dependencies: + iconv-lite "0.6.3" + whatwg-fetch@2.x: version "2.0.4" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f"