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 (
+ <>
+
+
+ {!isDatanodeRunning && }
+ {isDatanodeRunning && }
+ {isDatanodeRemoved && }
+ {(!isDatanodeRemoved || isRemovingDatanode) && }
+
+ {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 = () => (
+ (