diff --git a/data-node/src/main/java/org/graylog/datanode/management/OpensearchProcessImpl.java b/data-node/src/main/java/org/graylog/datanode/management/OpensearchProcessImpl.java index 8b9ce56c2b..85db4c16f5 100644 --- a/data-node/src/main/java/org/graylog/datanode/management/OpensearchProcessImpl.java +++ b/data-node/src/main/java/org/graylog/datanode/management/OpensearchProcessImpl.java @@ -214,6 +214,7 @@ class OpensearchProcessImpl implements OpensearchProcess, ProcessListener { @Override public void onReset() { onEvent(ProcessEvent.RESET); + restart(); } @Override diff --git a/data-node/src/main/java/org/graylog/datanode/management/OpensearchRemovalTracer.java b/data-node/src/main/java/org/graylog/datanode/management/OpensearchRemovalTracer.java index 7aa114bbf1..163ff7f73e 100644 --- a/data-node/src/main/java/org/graylog/datanode/management/OpensearchRemovalTracer.java +++ b/data-node/src/main/java/org/graylog/datanode/management/OpensearchRemovalTracer.java @@ -130,8 +130,6 @@ public class OpensearchRemovalTracer implements StateMachineTracer { if (health.getRelocatingShards() == 0) { process.stop(); executorService.shutdown(); - // TODO: set the state for this datanode to removed in nodes - // and include state in OpensearchProcessImpl.writeSeedHostsList } } catch (IOException | OpenSearchStatusException e) { process.onEvent(ProcessEvent.HEALTH_CHECK_FAILED); diff --git a/graylog2-server/src/main/java/org/graylog2/datanode/DataNodeServiceImpl.java b/graylog2-server/src/main/java/org/graylog2/datanode/DataNodeServiceImpl.java index 05d8c29561..4730da7978 100644 --- a/graylog2-server/src/main/java/org/graylog2/datanode/DataNodeServiceImpl.java +++ b/graylog2-server/src/main/java/org/graylog2/datanode/DataNodeServiceImpl.java @@ -48,6 +48,9 @@ public class DataNodeServiceImpl implements DataNodeService { if (nodeService.allActive().values().stream().anyMatch(n -> n.getDataNodeStatus() == DataNodeStatus.REMOVING)) { throw new IllegalArgumentException("Only one data node can be removed at a time."); } + if (node.getDataNodeStatus() != DataNodeStatus.AVAILABLE) { + throw new IllegalArgumentException("Only running data nodes can be removed from the cluster."); + } DataNodeLifecycleEvent e = DataNodeLifecycleEvent.create(node.getNodeId(), DataNodeLifecycleTrigger.REMOVE); clusterEventBus.post(e); return node; diff --git a/graylog2-server/src/main/java/org/graylog2/rest/resources/system/ClusterResource.java b/graylog2-server/src/main/java/org/graylog2/rest/resources/system/ClusterResource.java index 4d6d56b856..20ab71fa06 100644 --- a/graylog2-server/src/main/java/org/graylog2/rest/resources/system/ClusterResource.java +++ b/graylog2-server/src/main/java/org/graylog2/rest/resources/system/ClusterResource.java @@ -25,7 +25,6 @@ import io.swagger.annotations.ApiParam; import io.swagger.annotations.ApiResponse; import io.swagger.annotations.ApiResponses; import org.apache.shiro.authz.annotation.RequiresAuthentication; -import org.graylog.events.notifications.NotificationDto; import org.graylog.security.certutil.CertRenewalService; import org.graylog2.cluster.Node; import org.graylog2.cluster.NodeNotFoundException; @@ -77,16 +76,13 @@ public class ClusterResource extends RestResource { private static final ImmutableMap SEARCH_FIELD_MAPPING = ImmutableMap.builder() .put("id", SearchQueryField.create("_id", SearchQueryField.Type.OBJECT_ID)) - .put("title", SearchQueryField.create(NotificationDto.FIELD_TITLE)) - .put("description", SearchQueryField.create(NotificationDto.FIELD_DESCRIPTION)) + .put("hostname", SearchQueryField.create("hostname")) .build(); private static final String DEFAULT_SORT_FIELD = "title"; private static final String DEFAULT_SORT_DIRECTION = "asc"; private static final List attributes = List.of( - EntityAttribute.builder().id("title").title("Title").build(), - EntityAttribute.builder().id("description").title("Description").build(), - EntityAttribute.builder().id("type").title("Type").build() + EntityAttribute.builder().id("hostname").title("Name").build() ); private static final EntityDefaults settings = EntityDefaults.builder() .sort(Sorting.create(DEFAULT_SORT_FIELD, Sorting.Direction.valueOf(DEFAULT_SORT_DIRECTION.toUpperCase(Locale.ROOT)))) @@ -100,7 +96,7 @@ public class ClusterResource extends RestResource { this.nodeService = nodeService; this.dataNodePaginatedService = dataNodePaginatedService; this.certRenewalService = certRenewalService; - this.searchQueryParser = new SearchQueryParser(NotificationDto.FIELD_TITLE, SEARCH_FIELD_MAPPING); + this.searchQueryParser = new SearchQueryParser("hostname", SEARCH_FIELD_MAPPING); this.nodeId = nodeId; this.clusterId = clusterConfigService.getOrDefault(ClusterId.class, ClusterId.create(UUID.nilUUID().toString())); } diff --git a/graylog2-web-interface/src/components/bootstrap/Checkbox.tsx b/graylog2-web-interface/src/components/bootstrap/Checkbox.tsx index e3bd074c83..685821a3ef 100644 --- a/graylog2-web-interface/src/components/bootstrap/Checkbox.tsx +++ b/graylog2-web-interface/src/components/bootstrap/Checkbox.tsx @@ -24,4 +24,5 @@ type Props = Omit & { onChange: (e: React.ChangeEvent) => void, } const Checkbox = ({ onChange, ...props }: Props) => ; + export default Checkbox; diff --git a/graylog2-web-interface/src/components/common/QueryHelper.tsx b/graylog2-web-interface/src/components/common/QueryHelper.tsx index b680f2f91a..40cd855bf2 100644 --- a/graylog2-web-interface/src/components/common/QueryHelper.tsx +++ b/graylog2-web-interface/src/components/common/QueryHelper.tsx @@ -33,7 +33,7 @@ type CommonFields = keyof typeof COMMON_FIELD_MAP; type Props = { commonFields?: Array, fieldMap?: { [field: string]: string }, - example?: string, + example?: React.ReactNode, entityName?: string, }; diff --git a/graylog2-web-interface/src/components/datanode/DataNodeConfiguration/CertificateRenewal.tsx b/graylog2-web-interface/src/components/datanode/DataNodeConfiguration/CertificateRenewal.tsx index 7704f1e137..1f58058087 100644 --- a/graylog2-web-interface/src/components/datanode/DataNodeConfiguration/CertificateRenewal.tsx +++ b/graylog2-web-interface/src/components/datanode/DataNodeConfiguration/CertificateRenewal.tsx @@ -28,11 +28,12 @@ import { Spinner } from 'components/common'; import { Alert, ListGroup, ListGroupItem, Button } from 'components/bootstrap'; import { defaultCompare } from 'logic/DefaultCompare'; import useSendTelemetry from 'logic/telemetry/useSendTelemetry'; -import DataNodeBadge from 'components/datanode/DataNodeBadge'; import { Badge } from 'preflight/components/common'; import useLocation from 'routing/useLocation'; import { TELEMETRY_EVENT_TYPE } from 'logic/telemetry/Constants'; +import DataNodeBadge from '../DataNodeList/DataNodeBadge'; + const StyledList = styled(ListGroup)` max-width: fit-content; @@ -98,7 +99,7 @@ const provisioningWording = { buttonStyle: 'success', } as const; -const CertRenewalButton = ({ nodeId, status }: { nodeId: string, status: DataNode['status'] }) => { +export const CertRenewalButton = ({ nodeId, status }: { nodeId: string, status: DataNode['status'] }) => { const sendTelemetry = useSendTelemetry(); const { pathname } = useLocation(); const [isRenewing, setIsRenewing] = useState(false); diff --git a/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeActions.tsx b/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeActions.tsx new file mode 100644 index 0000000000..a5e6e04de9 --- /dev/null +++ b/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeActions.tsx @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; +import { useState } from 'react'; + +import type { DataNode } from 'preflight/types'; +import { ConfirmDialog } from 'components/common'; +import { MenuItem } from 'components/bootstrap'; +import OverlayDropdownButton from 'components/common/OverlayDropdownButton'; +import { MORE_ACTIONS_TITLE, MORE_ACTIONS_HOVER_TITLE } from 'components/common/EntityDataTable/Constants'; + +import { + rejoinDataNode, + removeDataNode, + renewDatanodeCertificate, + stopDataNode, + startDataNode, +} from '../hooks/useDataNodes'; + +type Props = { + dataNode: DataNode, +}; + +const DIALOG_TYPES = { + STOP: 'stop', + REJOIN: 'rejoin', + REMOVE: 'remove', + RENEW_CERT: 'renew', +}; + +const DIALOG_TEXT = { + [DIALOG_TYPES.REJOIN]: { + dialogTitle: 'Rejoin Data Node', + dialogBody: (datanode: string) => `Are you sure you want to rejoin Data Node "${datanode}"?`, + }, + [DIALOG_TYPES.REMOVE]: { + dialogTitle: 'Remove Data Node', + dialogBody: (datanode: string) => `Are you sure you want to remove Data Node "${datanode}"?`, + }, + [DIALOG_TYPES.STOP]: { + dialogTitle: 'Stop Data Node', + dialogBody: (datanode: string) => `Are you sure you want to stop Data Node "${datanode}"?`, + }, +}; + +const DataNodeActions = ({ dataNode }: Props) => { + const [showDialog, setShowDialog] = useState(false); + const [dialogType, setDialogType] = useState(null); + + const updateState = ({ show, type }) => { + setShowDialog(show); + setDialogType(type); + }; + + const handleAction = (action) => { + switch (action) { + case DIALOG_TYPES.REJOIN: + updateState({ show: true, type: DIALOG_TYPES.REJOIN }); + + break; + case DIALOG_TYPES.REMOVE: + updateState({ show: true, type: DIALOG_TYPES.REMOVE }); + + break; + case DIALOG_TYPES.STOP: + updateState({ show: true, type: DIALOG_TYPES.STOP }); + + break; + default: + break; + } + }; + + const handleClearState = () => { + updateState({ show: false, type: null }); + }; + + const handleConfirm = () => { + switch (dialogType) { + case 'rejoin': + rejoinDataNode(dataNode.node_id).then(() => { + handleClearState(); + }); + + break; + case 'remove': + removeDataNode(dataNode.node_id).then(() => { + handleClearState(); + }); + + break; + case 'stop': + stopDataNode(dataNode.node_id).then(() => { + handleClearState(); + }); + + break; + default: + break; + } + }; + + const isDatanodeRunning = dataNode.data_node_status === 'AVAILABLE'; + const isDatanodeRemoved = dataNode.data_node_status === 'REMOVED'; + const isRemovingDatanode = dataNode.data_node_status === 'REMOVING'; + + return ( + <> + + renewDatanodeCertificate(dataNode.node_id)}>Renew certificate + {!isDatanodeRunning && startDataNode(dataNode.node_id)}>Start} + {isDatanodeRunning && handleAction(DIALOG_TYPES.STOP)}>Stop} + {isDatanodeRemoved && handleAction(DIALOG_TYPES.REJOIN)}>Rejoin} + {(!isDatanodeRemoved || isRemovingDatanode) && handleAction(DIALOG_TYPES.REMOVE)}>Remove} + + {showDialog && ( + + {DIALOG_TEXT[dialogType].dialogBody(dataNode.hostname)} + + )} + + ); +}; + +export default DataNodeActions; diff --git a/graylog2-web-interface/src/components/datanode/DataNodeBadge.tsx b/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeBadge.tsx similarity index 97% rename from graylog2-web-interface/src/components/datanode/DataNodeBadge.tsx rename to graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeBadge.tsx index bbe1bcc712..aec9442db0 100644 --- a/graylog2-web-interface/src/components/datanode/DataNodeBadge.tsx +++ b/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeBadge.tsx @@ -21,7 +21,7 @@ import type { DataNodeStatus } from 'preflight/types'; import { Badge } from 'preflight/components/common'; import Icon from 'preflight/components/common/Icon'; -import Spinner from '../common/Spinner'; +import Spinner from '../../common/Spinner'; const NodeId = styled(Badge)` margin-right: 3px; diff --git a/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeList.tsx b/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeList.tsx new file mode 100644 index 0000000000..0ad8ab8d45 --- /dev/null +++ b/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeList.tsx @@ -0,0 +1,165 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import React, { useEffect } from 'react'; +import styled from 'styled-components'; +import { useQueryParam, StringParam } from 'use-query-params'; + +import type { DataNode, DataNodeStatus } from 'preflight/types'; +import { PaginatedList, SearchForm, NoSearchResult, RelativeTime } from 'components/common'; +import Spinner from 'components/common/Spinner'; +import QueryHelper from 'components/common/QueryHelper'; +import usePaginationQueryParameter from 'hooks/usePaginationQueryParameter'; +import type { ColumnRenderers } from 'components/common/EntityDataTable'; +import EntityDataTable from 'components/common/EntityDataTable'; +import useUpdateUserLayoutPreferences from 'components/common/EntityDataTable/hooks/useUpdateUserLayoutPreferences'; +import useTableLayout from 'components/common/EntityDataTable/hooks/useTableLayout'; +import type { Sort } from 'stores/PaginationTypes'; +import useTableEventHandlers from 'hooks/useTableEventHandlers'; +import { Link } from 'components/common/router'; +import Routes from 'routing/Routes'; + +import DataNodeActions from './DataNodeActions'; +import DataNodeStatusCell from './DataNodeStatusCell'; + +import useDataNodes from '../hooks/useDataNodes'; + +const ENTITY_TABLE_ID = 'datanodes'; +const DEFAULT_LAYOUT = { + pageSize: 10, + sort: { attributeId: 'title', direction: 'asc' } as Sort, + displayedColumns: ['hostname', 'transport_address', 'status', 'is_leader', 'cert_valid_until'], + columnsOrder: ['hostname', 'transport_address', 'status', 'is_leader', 'cert_valid_until'], +}; + +const columnDefinitions = [ + { id: 'hostname', title: 'Name', sortable: true, permissions: [] }, + { id: 'transport_address', title: 'Transport address' }, + { id: 'status', title: 'Status', sortable: false }, + { id: 'is_leader', title: 'Is leader', sortable: true }, + { id: 'cert_valid_until', title: 'Certificate valid until', sortable: false }, +]; + +const columnRenderers: ColumnRenderers = { + attributes: { + hostname: { + renderCell: (_hostname: string, dataNode: DataNode) => ( + + {dataNode.hostname} + + ), + }, + status: { + renderCell: (_status: DataNodeStatus, dataNode: DataNode) => , + }, + is_leader: { + renderCell: (_is_leader: string, dataNode: DataNode) => (dataNode.is_leader ? 'yes' : 'no'), + }, + cert_valid_until: { + renderCell: (_cert_valid_until: string, dataNode: DataNode) => , + }, + }, +}; + +const entityActions = (dataNode: DataNode) => ( + +); + +const SearchContainer = styled.div` + margin-bottom: 5px; +`; + +const DataNodeList = () => { + const [query, setQuery] = useQueryParam('query', StringParam); + const { layoutConfig, isInitialLoading: isLoadingLayoutPreferences } = useTableLayout({ + entityTableId: ENTITY_TABLE_ID, + defaultPageSize: DEFAULT_LAYOUT.pageSize, + defaultDisplayedAttributes: DEFAULT_LAYOUT.displayedColumns, + defaultSort: DEFAULT_LAYOUT.sort, + }); + const paginationQueryParameter = usePaginationQueryParameter(undefined, layoutConfig.pageSize, false); + const { mutate: updateTableLayout } = useUpdateUserLayoutPreferences(ENTITY_TABLE_ID); + const { data: { elements, pagination: { total } }, isInitialLoading: isInitialLoadingDataNodes, refetch } = useDataNodes({ + query: query, + page: paginationQueryParameter.page, + pageSize: layoutConfig.pageSize, + sort: layoutConfig.sort, + }, { enabled: !isLoadingLayoutPreferences }); + + useEffect(() => { + refetch(); + }, [query, paginationQueryParameter.page, layoutConfig.pageSize, layoutConfig.sort, refetch]); + + const { + onPageSizeChange, + onSearch, + onSearchReset, + onColumnsChange, + onSortChange, + } = useTableEventHandlers({ + paginationQueryParameter, + updateTableLayout, + setQuery, + }); + + if (isLoadingLayoutPreferences || isInitialLoadingDataNodes) { + return ; + } + + return ( + + + + Find entities with a description containing node:
+ name:node
+

+ )} /> + )} /> +
+
+ {elements?.length === 0 ? ( + No Data Nodes have been found + ) : ( + data={elements} + visibleColumns={layoutConfig.displayedAttributes} + columnsOrder={DEFAULT_LAYOUT.columnsOrder} + onColumnsChange={onColumnsChange} + onSortChange={onSortChange} + onPageSizeChange={onPageSizeChange} + pageSize={layoutConfig.pageSize} + bulkSelection={{}} + activeSort={layoutConfig.sort} + rowActions={entityActions} + actionsCellWidth={160} + columnRenderers={columnRenderers} + columnDefinitions={columnDefinitions} + entityAttributesAreCamelCase={false} /> + )} +
+
+ ); +}; + +export default DataNodeList; diff --git a/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeStatusCell.tsx b/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeStatusCell.tsx new file mode 100644 index 0000000000..54bad4bd00 --- /dev/null +++ b/graylog2-web-interface/src/components/datanode/DataNodeList/DataNodeStatusCell.tsx @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; +import styled from 'styled-components'; + +import { Label } from 'components/bootstrap'; +import type { DataNode } from 'preflight/types'; + +const StatusLabel = styled(Label)` + display: inline-flex; + justify-content: center; + gap: 4px; +`; + +type Props = { + dataNode: DataNode, +}; + +const DataNodeStatusCell = ({ dataNode }: Props) => { + const datanodeDisabled = dataNode.data_node_status !== 'AVAILABLE'; + + return ( + + {dataNode.data_node_status} + + ); +}; + +export default DataNodeStatusCell; diff --git a/graylog2-web-interface/src/components/datanode/DataNodesPageNavigation.tsx b/graylog2-web-interface/src/components/datanode/DataNodePageNavigation.tsx similarity index 81% rename from graylog2-web-interface/src/components/datanode/DataNodesPageNavigation.tsx rename to graylog2-web-interface/src/components/datanode/DataNodePageNavigation.tsx index 82f0293462..875b0661fa 100644 --- a/graylog2-web-interface/src/components/datanode/DataNodesPageNavigation.tsx +++ b/graylog2-web-interface/src/components/datanode/DataNodePageNavigation.tsx @@ -21,9 +21,9 @@ import Routes from 'routing/Routes'; import { Row } from 'components/bootstrap'; const NAV_ITEMS = [ - { title: 'Overview', path: Routes.SYSTEM.DATANODES.OVERVIEW, exactPathMatch: true }, - { title: 'Monitoring & Management', path: Routes.SYSTEM.DATANODES.OVERVIEW }, - { title: 'Migrations', path: Routes.SYSTEM.DATANODES.OVERVIEW }, + { title: 'Data Nodes', path: Routes.SYSTEM.DATANODES.LIST, exactPathMatch: true }, + { title: 'Cluster', path: Routes.SYSTEM.DATANODES.CLUSTER }, + { title: 'Migration', path: Routes.SYSTEM.DATANODES.MIGRATION }, ]; const DataNodesPageNavigation = () => ( diff --git a/graylog2-web-interface/src/components/datanode/hooks/useDataNodes.ts b/graylog2-web-interface/src/components/datanode/hooks/useDataNodes.ts new file mode 100644 index 0000000000..5657a62005 --- /dev/null +++ b/graylog2-web-interface/src/components/datanode/hooks/useDataNodes.ts @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import { useQuery } from '@tanstack/react-query'; + +import { qualifyUrl } from 'util/URLUtils'; +import PaginationURL from 'util/PaginationURL'; +import type { DataNode } from 'preflight/types'; +import UserNotification from 'util/UserNotification'; +import fetch from 'logic/rest/FetchProvider'; +import type { Attribute, PaginatedListJSON, SearchParams } from 'stores/PaginationTypes'; + +export const removeDataNode = async (datanodeId: string) => { + try { + await fetch('DELETE', qualifyUrl(`/datanode/${datanodeId}`)); + + UserNotification.success(`Data Node "${datanodeId}" removed successfully`); + } catch (errorThrown) { + UserNotification.error(`Removing Data Node failed with status: ${errorThrown}`, 'Could not remove the Data Node.'); + } +}; + +export const startDataNode = async (datanodeId: string) => { + try { + await fetch('POST', qualifyUrl(`/datanode/${datanodeId}/start`)); + + UserNotification.success(`Data Node "${datanodeId}" started successfully`); + } catch (errorThrown) { + UserNotification.error(`Starting Data Node failed with status: ${errorThrown}`, 'Could not start the Data Node.'); + } +}; + +export const stopDataNode = async (datanodeId: string) => { + try { + await fetch('POST', qualifyUrl(`/datanode/${datanodeId}/stop`)); + + UserNotification.success(`Data Node "${datanodeId}" stopped successfully`); + } catch (errorThrown) { + UserNotification.error(`Stopping Data Node failed with status: ${errorThrown}`, 'Could not stop the Data Node.'); + } +}; + +export const rejoinDataNode = async (datanodeId: string) => { + try { + await fetch('POST', qualifyUrl(`/datanode/${datanodeId}/reset`)); + + UserNotification.success(`Data Node "${datanodeId}" rejoined successfully`); + } catch (errorThrown) { + UserNotification.error(`Rejoining Data Node failed with status: ${errorThrown}`, 'Could not rejoin the Data Node.'); + } +}; + +type Options = { + enabled: boolean, +} + +export const renewDatanodeCertificate = (nodeId: string) => fetch('POST', qualifyUrl(`/certrenewal/${nodeId}`)) + .then(() => { + UserNotification.success('Certificate renewed successfully'); + }) + .catch((error) => { + UserNotification.error(`Certificate renewal failed with error: ${error}`); + }); + +const fetchDataNodes = async (params?: SearchParams) => { + const url = PaginationURL('/system/cluster/datanodes', params?.page, params?.pageSize, params?.query, { sort: params?.sort?.attributeId, order: params?.sort?.direction }); + + return fetch('GET', qualifyUrl(url)); +}; + +const useDataNodes = (params: SearchParams, { enabled }: Options = { enabled: true }) : { + data: { + elements: Array, + pagination: PaginatedListJSON, + attributes: Array + }, + refetch: () => void, + isInitialLoading: boolean, +} => { + const { data, refetch, isInitialLoading } = useQuery( + ['datanodes'], + () => fetchDataNodes(params), + { + onError: (errorThrown) => { + UserNotification.error(`Loading Data Nodes failed with status: ${errorThrown}`, + 'Could not load Data Nodes'); + }, + notifyOnChangeProps: ['data', 'error'], + refetchInterval: 5000, + enabled, + }, + ); + + return ({ + data: data || { + attributes: [], + elements: [], + pagination: { total: 0 }, + }, + refetch, + isInitialLoading, + }); +}; + +export default useDataNodes; diff --git a/graylog2-web-interface/src/components/navigation/bindings.ts b/graylog2-web-interface/src/components/navigation/bindings.ts index a16b3dd9de..bdf04d90fb 100644 --- a/graylog2-web-interface/src/components/navigation/bindings.ts +++ b/graylog2-web-interface/src/components/navigation/bindings.ts @@ -48,7 +48,7 @@ const navigationBindings = { { path: Routes.SYSTEM.OVERVIEW, description: 'Overview' }, { path: Routes.SYSTEM.CONFIGURATIONS, description: 'Configurations', permissions: ['clusterconfigentry:read'] }, { path: Routes.SYSTEM.NODES.LIST, description: 'Nodes' }, - { path: Routes.SYSTEM.DATANODES.OVERVIEW, description: 'Data Nodes' }, + { path: Routes.SYSTEM.DATANODES.LIST, description: 'Data Nodes' }, { path: Routes.SYSTEM.INPUTS, description: 'Inputs', permissions: ['inputs:read'] }, { path: Routes.SYSTEM.OUTPUTS, description: 'Outputs', permissions: ['outputs:read'] }, { path: Routes.SYSTEM.INDICES.LIST, description: 'Indices', permissions: ['indices:read'] }, @@ -64,7 +64,7 @@ const navigationBindings = { ], AppConfig.isCloud() && !AppConfig.isFeatureEnabled('cloud_inputs') ? [Routes.SYSTEM.INPUTS] : [], ), - [Routes.SYSTEM.NODES.LIST, Routes.SYSTEM.DATANODES.OVERVIEW, Routes.SYSTEM.OUTPUTS, Routes.SYSTEM.LOGGING, Routes.SYSTEM.AUTHENTICATION.BACKENDS.ACTIVE], + [Routes.SYSTEM.NODES.LIST, Routes.SYSTEM.DATANODES.LIST, Routes.SYSTEM.OUTPUTS, Routes.SYSTEM.LOGGING, Routes.SYSTEM.AUTHENTICATION.BACKENDS.ACTIVE], ), }, ], diff --git a/graylog2-web-interface/src/hooks/useTableEventHandlers.ts b/graylog2-web-interface/src/hooks/useTableEventHandlers.ts new file mode 100644 index 0000000000..282ce58c94 --- /dev/null +++ b/graylog2-web-interface/src/hooks/useTableEventHandlers.ts @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import { useCallback } from 'react'; + +import type { Sort } from 'stores/PaginationTypes'; + +const useTableEventHandlers = ({ updateTableLayout, paginationQueryParameter, setQuery }: { updateTableLayout, paginationQueryParameter, setQuery }) => { + const onPageSizeChange = useCallback((newPageSize: number) => { + paginationQueryParameter.setPagination({ page: 1, pageSize: newPageSize }); + updateTableLayout({ perPage: newPageSize }); + }, [paginationQueryParameter, updateTableLayout]); + + const onSearch = useCallback((newQuery: string) => { + paginationQueryParameter.resetPage(); + setQuery(newQuery); + }, [paginationQueryParameter, setQuery]); + + const onSearchReset = useCallback(() => { + onSearch(''); + }, [onSearch]); + + const onColumnsChange = useCallback((displayedAttributes: Array) => { + updateTableLayout({ displayedAttributes }); + }, [updateTableLayout]); + + const onSortChange = useCallback((newSort: Sort) => { + paginationQueryParameter.resetPage(); + updateTableLayout({ sort: newSort }); + }, [paginationQueryParameter, updateTableLayout]); + + return { + onPageSizeChange, + onSearch, + onSearchReset, + onColumnsChange, + onSortChange, + }; +}; + +export default useTableEventHandlers; diff --git a/graylog2-web-interface/src/pages/DataNodePage.tsx b/graylog2-web-interface/src/pages/DataNodePage.tsx new file mode 100644 index 0000000000..fb951c23d8 --- /dev/null +++ b/graylog2-web-interface/src/pages/DataNodePage.tsx @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import * as React from 'react'; +import styled, { css } from 'styled-components'; + +import useParams from 'routing/useParams'; +import useDataNodes from 'components/datanode/hooks/useDataNodes'; +import DataNodesPageNavigation from 'components/datanode/DataNodePageNavigation'; +import DocsHelper from 'util/DocsHelper'; +import { Row, Col, Label } from 'components/bootstrap'; +import { DocumentTitle, PageHeader, RelativeTime, Spinner } from 'components/common'; +import type { SearchParams } from 'stores/PaginationTypes'; +import { CertRenewalButton } from 'components/datanode/DataNodeConfiguration/CertificateRenewal'; +import Icon from 'components/common/Icon'; + +const StyledHorizontalDl = styled.dl(({ theme }) => css` + margin: ${theme.spacings.md} 0; + + > dt { + clear: left; + float: left; + margin-bottom: ${theme.spacings.md}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + width: 160px; + } + + > *:not(dt) { + margin-bottom: ${theme.spacings.md}; + margin-left: 140px; + } +`); +const StatusLabel = styled(Label)` + display: inline-flex; + justify-content: center; + gap: 4px; +`; +const BooleanIcon = styled(Icon)<{ value: boolean }>(({ theme, value }) => css` + color: ${value ? theme.colors.variant.success : theme.colors.variant.danger}; +`); +const BooleanValue = ({ value }: { value: boolean }) => ( + <> {value ? 'yes' : 'no'} +); + +const DataNodePage = () => { + const { dataNodeId } = useParams(); + const { data: { elements }, isInitialLoading } = useDataNodes({ + query: '', + page: 1, + pageSize: 0, + } as SearchParams); + + if (isInitialLoading) { + return ; + } + + const datanode = elements.find((node) => node.node_id === dataNodeId); + const datanodeDisabled = datanode.data_node_status !== 'AVAILABLE'; + + return ( + + + + + + +

Details:

+ +
Hostname:
+
{datanode.hostname}
+
Transport address:
+
{datanode.transport_address || '-'}
+
Status:
+
+ + {datanode.data_node_status || 'N/A'} + +
+
Is leader:
+
+
Certificate valid until:
+
+
+ + +
+
+ ); +}; + +export default DataNodePage; diff --git a/graylog2-web-interface/src/pages/DataNodesClusterPage.tsx b/graylog2-web-interface/src/pages/DataNodesClusterPage.tsx new file mode 100644 index 0000000000..ffc7d8950c --- /dev/null +++ b/graylog2-web-interface/src/pages/DataNodesClusterPage.tsx @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import React from 'react'; + +import { Row, Col } from 'components/bootstrap'; +import { DocumentTitle, PageHeader } from 'components/common'; +import DocsHelper from 'util/DocsHelper'; +import DataNodeConfiguration from 'components/datanode/DataNodeConfiguration/DataNodeConfiguration'; +import DataNodesPageNavigation from 'components/datanode/DataNodePageNavigation'; + +const DataNodesClusterPage = () => ( + + + + + Graylog Data Nodes offer a better integration with Graylog and simplify future updates. They allow you to index and search through all the messages in your Graylog message database. + + + + + + + + +); + +export default DataNodesClusterPage; diff --git a/graylog2-web-interface/src/pages/DataNodesMigrationPage.tsx b/graylog2-web-interface/src/pages/DataNodesMigrationPage.tsx new file mode 100644 index 0000000000..7c8726b58e --- /dev/null +++ b/graylog2-web-interface/src/pages/DataNodesMigrationPage.tsx @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2020 Graylog, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ +import React from 'react'; + +import { Row, Col } from 'components/bootstrap'; +import { DocumentTitle, PageHeader } from 'components/common'; +import DocsHelper from 'util/DocsHelper'; +import DataNodesPageNavigation from 'components/datanode/DataNodePageNavigation'; + +const DataNodesMigrationPage = () => ( + + + + + Graylog Data Nodes offer a better integration with Graylog and simplify future updates. They allow you to index and search through all the messages in your Graylog message database. + + + + + TODO: Migration Component + + + +); + +export default DataNodesMigrationPage; diff --git a/graylog2-web-interface/src/pages/DataNodesPage.tsx b/graylog2-web-interface/src/pages/DataNodesPage.tsx index c0efedb0cd..1eb59703e4 100644 --- a/graylog2-web-interface/src/pages/DataNodesPage.tsx +++ b/graylog2-web-interface/src/pages/DataNodesPage.tsx @@ -19,10 +19,12 @@ import React from 'react'; import { Row, Col } from 'components/bootstrap'; import { DocumentTitle, PageHeader } from 'components/common'; import DocsHelper from 'util/DocsHelper'; -import DataNodeConfiguration from 'components/datanode/DataNodeConfiguration/DataNodeConfiguration'; +import DataNodesPageNavigation from 'components/datanode/DataNodePageNavigation'; +import DataNodeList from 'components/datanode/DataNodeList/DataNodeList'; const DataNodesPage = () => ( + ( - + diff --git a/graylog2-web-interface/src/pages/index.jsx b/graylog2-web-interface/src/pages/index.jsx index b6a9dc6966..322c8d1f13 100644 --- a/graylog2-web-interface/src/pages/index.jsx +++ b/graylog2-web-interface/src/pages/index.jsx @@ -31,6 +31,8 @@ const CreateEventNotificationPage = loadAsync(() => import('./CreateEventNotific const CreateContentPackPage = loadAsync(() => import('pages/CreateContentPackPage')); const CreateExtractorsPage = loadAsync(() => import('./CreateExtractorsPage')); const DataNodesPage = loadAsync(() => import('./DataNodesPage')); +const DataNodesClusterPage = loadAsync(() => import('./DataNodesClusterPage')); +const DataNodesMigrationPage = loadAsync(() => import('./DataNodesMigrationPage')); const DelegatedSearchPage = loadAsync(() => import('./DelegatedSearchPage')); const EditEventDefinitionPage = loadAsync(() => import('./EditEventDefinitionPage')); const EditEventNotificationPage = loadAsync(() => import('./EditEventNotificationPage')); @@ -116,7 +118,9 @@ export { CreateEventNotificationPage, CreateContentPackPage, CreateExtractorsPage, + DataNodesClusterPage, DataNodesPage, + DataNodesMigrationPage, DelegatedSearchPage, EditEventDefinitionPage, EditEventNotificationPage, diff --git a/graylog2-web-interface/src/preflight/components/Setup/DataNodesOverview.tsx b/graylog2-web-interface/src/preflight/components/Setup/DataNodesOverview.tsx index 440a272914..d6bba1ed12 100644 --- a/graylog2-web-interface/src/preflight/components/Setup/DataNodesOverview.tsx +++ b/graylog2-web-interface/src/preflight/components/Setup/DataNodesOverview.tsx @@ -21,8 +21,8 @@ import styled from 'styled-components'; import Spinner from 'components/common/Spinner'; import Alert from 'components/bootstrap/Alert'; import useDataNodes from 'preflight/hooks/useDataNodes'; -import DataNodeBadge from 'components/datanode/DataNodeBadge'; import { Badge, List } from 'preflight/components/common'; +import DataNodeBadge from 'components/datanode/DataNodeList/DataNodeBadge'; const P = styled.p` max-width: 700px; diff --git a/graylog2-web-interface/src/preflight/types.ts b/graylog2-web-interface/src/preflight/types.ts index f1a209846e..bec33b68ba 100644 --- a/graylog2-web-interface/src/preflight/types.ts +++ b/graylog2-web-interface/src/preflight/types.ts @@ -28,7 +28,8 @@ export type DataNode = { short_node_id: string, transport_address: string, type: string, - status: DataNodeStatus + status: DataNodeStatus, + data_node_status?: string, cert_valid_until: string | null, error_msg?: string, } diff --git a/graylog2-web-interface/src/routing/AppRouter.tsx b/graylog2-web-interface/src/routing/AppRouter.tsx index 1cd83f3b09..6eb9e129ea 100644 --- a/graylog2-web-interface/src/routing/AppRouter.tsx +++ b/graylog2-web-interface/src/routing/AppRouter.tsx @@ -34,6 +34,8 @@ import { CreateEventNotificationPage, CreateExtractorsPage, DataNodesPage, + DataNodesClusterPage, + DataNodesMigrationPage, DelegatedSearchPage, EditEventDefinitionPage, EditEventNotificationPage, @@ -109,6 +111,7 @@ import RoutePaths from 'routing/Routes'; import RouterErrorBoundary from 'components/errors/RouterErrorBoundary'; import usePluginEntities from 'hooks/usePluginEntities'; import GlobalContextProviders from 'contexts/GlobalContextProviders'; +import DataNodePage from 'pages/DataNodePage'; const renderPluginRoute = ({ path, component: Component, parentComponent, requiredFeatureFlag }: PluginRoute) => { if (requiredFeatureFlag && !AppConfig.isFeatureEnabled(requiredFeatureFlag)) { @@ -244,7 +247,11 @@ const AppRouter = () => { { path: RoutePaths.SYSTEM.METRICS(':nodeId'), element: }, !isCloud && { path: RoutePaths.SYSTEM.NODES.LIST, element: }, !isCloud && { path: RoutePaths.SYSTEM.NODES.SHOW(':nodeId'), element: }, - !isCloud && { path: RoutePaths.SYSTEM.DATANODES.OVERVIEW, element: }, + !isCloud && { path: RoutePaths.SYSTEM.DATANODES.LIST, element: }, + !isCloud && { path: RoutePaths.SYSTEM.DATANODES.CLUSTER, element: }, + !isCloud && { path: RoutePaths.SYSTEM.DATANODES.MIGRATION, element: }, + !isCloud && { path: RoutePaths.SYSTEM.DATANODES.SHOW(':dataNodeId'), element: }, + !isCloud && { path: RoutePaths.SYSTEM.OUTPUTS, element: }, !isCloud && ( diff --git a/graylog2-web-interface/src/routing/Routes.ts b/graylog2-web-interface/src/routing/Routes.ts index ccebfb34e0..273232fd47 100644 --- a/graylog2-web-interface/src/routing/Routes.ts +++ b/graylog2-web-interface/src/routing/Routes.ts @@ -112,7 +112,10 @@ const Routes = { SHOW: (nodeId: string) => `/system/nodes/${nodeId}`, }, DATANODES: { - OVERVIEW: '/system/datanodes', + LIST: '/system/datanodes', + SHOW: (dataNodeId: string) => `/system/datanodes/${dataNodeId}`, + CLUSTER: '/system/datanodes/cluster', + MIGRATION: '/system/datanodes/migration', }, THREADDUMP: (nodeId: string) => `/system/threaddump/${nodeId}`, OUTPUTS: '/system/outputs', diff --git a/graylog2-web-interface/yarn.lock b/graylog2-web-interface/yarn.lock index ee3dc26de6..dc56a1900c 100644 --- a/graylog2-web-interface/yarn.lock +++ b/graylog2-web-interface/yarn.lock @@ -7112,7 +7112,7 @@ eslint-config-airbnb@19.0.4: eslint-config-airbnb "19.0.4" eslint-import-resolver-webpack "0.13.8" eslint-plugin-compat "4.2.0" - eslint-plugin-graylog "file:packages/eslint-plugin-graylog" + eslint-plugin-graylog "file:../../../../.cache/yarn/v6/npm-eslint-config-graylog-1.3.0-4edadc75-4cfc-4243-b27d-9e50edb45b57-1701345941843/node_modules/eslint-plugin-graylog" eslint-plugin-import "2.25.3" eslint-plugin-jest "27.6.0" eslint-plugin-jest-dom "5.1.0" @@ -8557,13 +8557,13 @@ graphemer@^1.4.0: "@types/create-react-class" "15.6.6" "@types/jquery" "3.5.29" "@types/react" "18.2.20" - babel-preset-graylog "file:packages/babel-preset-graylog" + babel-preset-graylog "file:../../../../.cache/yarn/v6/npm-graylog-web-plugin-6.0.0-SNAPSHOT-1e4c00e3-405d-490c-845e-7977cf077b0e-1701345941699/node_modules/babel-preset-graylog" create-react-class "15.7.0" - eslint-config-graylog "file:packages/eslint-config-graylog" + eslint-config-graylog "file:../../../../.cache/yarn/v6/npm-graylog-web-plugin-6.0.0-SNAPSHOT-1e4c00e3-405d-490c-845e-7977cf077b0e-1701345941699/node_modules/eslint-config-graylog" formik "2.4.5" history "^5.3.0" html-webpack-plugin "^5.5.0" - jest-preset-graylog "file:packages/jest-preset-graylog" + jest-preset-graylog "file:../../../../.cache/yarn/v6/npm-graylog-web-plugin-6.0.0-SNAPSHOT-1e4c00e3-405d-490c-845e-7977cf077b0e-1701345941699/node_modules/jest-preset-graylog" jquery "3.7.1" moment "2.29.4" moment-timezone "0.5.43"