Skip to content

Commit

Permalink
Merge pull request #24 from batzionb/roles
Browse files Browse the repository at this point in the history
Enabled selecting nodes by role
  • Loading branch information
batzionb authored Mar 16, 2023
2 parents d67bd1b + 18fe5f3 commit e8c918b
Show file tree
Hide file tree
Showing 15 changed files with 459 additions and 219 deletions.
9 changes: 9 additions & 0 deletions http-server.sh
Original file line number Diff line number Diff line change
@@ -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
21 changes: 15 additions & 6 deletions locales/en/plugin__node-remediation-console-plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,32 +37,37 @@
"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",
"Disk pressure": "Disk pressure",
"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",
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions src/apis/useSelectedNodes.tsx
Original file line number Diff line number Diff line change
@@ -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<NodeKind[]>(resource);
};

export default useSelectedNodes;
Original file line number Diff line number Diff line change
@@ -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<string[]>(fieldName);

const [roleLabels, setRoleLabels] = React.useState<ClusterRoleLabels>({});
const [selectGroups, setSelectGroups] = React.useState<JSX.Element[]>([]);
const memoValue = useDeepCompareMemoize<string[]>(field.value);
const [allLabels, setAllLabels] = React.useState<string[]>([]);

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(
<SelectGroup label={t("Role")}>
<SelectOption value={_roleLabels[Role.CONTROL_PLANE]}>
{t("Control plane")}
</SelectOption>
<SelectOption value={_roleLabels[Role.WORKER]}>
{t("Worker")}
</SelectOption>
</SelectGroup>
);
}
_selectGroups.push(
<SelectGroup label={t("Label")}>
{getAllNodesLabels(allNodes).map((option) => (
<SelectOption value={option}>{option}</SelectOption>
))}
</SelectGroup>
);
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 (
<Stack hasGutter>
<StackItem style={{ marginBottom: "var(--pf-global--spacer--sm)" }}>
<MultiSelectField
options={selectGroups}
enableClear={true}
isLoading={isLoading}
name={fieldName}
label={t("Nodes selection")}
helpText={t(
"Select the labels that will be used to find unhealthy nodes for remediation. The nodes must satisfy all selected labels."
)}
isRequired={true}
/>
</StackItem>
{!isLoading && (
<StackItem>
<Flex flexWrap={{ default: "nowrap" }}>
{getSelectedRoleLabels().length > 0 && (
<FlexItem>
<ChipGroup
key="roles"
categoryName={"Role"}
collapsedText={t("Show more")}
expandedText={t("Show less")}
defaultIsOpen={true}
>
{getSelectedRoleLabels().map((label) => (
<Chip key={label} onClick={() => onDeleteLabel(label)}>
{label === roleLabels[Role.CONTROL_PLANE]
? t("Control plane")
: t("Worker")}
</Chip>
))}
</ChipGroup>
</FlexItem>
)}
<FlexItem>
<ChipGroup
key="labels"
categoryName={"Labels"}
collapsedText={t("Show more")}
expandedText={t("Show less")}
defaultIsOpen={true}
numChips={2}
>
{field.value.map((label) => (
<Chip key={label} onClick={() => onDeleteLabel(label)}>
{label}
</Chip>
))}
</ChipGroup>
</FlexItem>
</Flex>
</StackItem>
)}
</Stack>
);
};

export default LabelSelectionField;
83 changes: 27 additions & 56 deletions src/components/editor/formView/nodeSelectionField/NodeList.tsx
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -72,7 +69,7 @@ const NodeRow: React.FC<RowProps<NodeKind>> = ({ obj, activeColumnIDs }) => {
/>
</TableData>
<TableData id={columns[2].id} activeColumnIDs={activeColumnIDs}>
<NodeStatus node={obj}></NodeStatus>
<NodeStatus node={obj} />
</TableData>
<TableData id={columns[1].id} activeColumnIDs={activeColumnIDs}>
{getNodeRolesText(obj)}
Expand All @@ -81,61 +78,35 @@ const NodeRow: React.FC<RowProps<NodeKind>> = ({ obj, activeColumnIDs }) => {
);
};

const EmptyMsg = () => {
const { t } = useNodeHealthCheckTranslation();
return (
<EmptyState>
<Title headingLevel="h2" size="lg">
{t("No nodes match the selected labels")}
</Title>
</EmptyState>
);
const getEmptyMsg = (selectedLabels: string[]) => {
const component = () => {
const { t } = useNodeHealthCheckTranslation();
return (
<EmptyState>
<Title headingLevel="h2" size="lg">
{selectedLabels.length === 0
? t("No nodes were selected, use filter to select nodes")
: t("No nodes match the selected labels")}
</Title>
</EmptyState>
);
};
return component;
};

const NodeList: React.FC<{
allNodes: NodeKind[];
fieldName: string;
allNodesLoaded: boolean;
}> = ({ allNodes, fieldName, allNodesLoaded }) => {
const NodeList = ({ fieldName }: { fieldName: string }) => {
const [{ value }] = useField<string[]>(fieldName);
const [nodeModel, modelLoading] = useK8sModel(nodeKind);
const [selectedNodes, setSelectedNodes] = React.useState<NodeKind[]>([]);
const [loading, setLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState<unknown>();
const memoValue = useDeepCompareMemoize<string[]>(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 (
<VirtualizedTable<NodeKind>
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)}
/>
);
};
Expand Down
Loading

0 comments on commit e8c918b

Please sign in to comment.