Mesh UI support

- add endpoint
- delete endpoint (wip)
- associate
- disassociate
This commit is contained in:
David O Neill 2024-01-04 17:04:56 +00:00 committed by Seth Foster
parent 93500f9fea
commit 82ad7dcf40
13 changed files with 838 additions and 5 deletions

View File

@ -32,6 +32,10 @@ class Instances extends Base {
return this.http.get(`${this.baseUrl}${instanceId}/receptor_addresses/`);
}
updateReceptorAddresses(instanceId, data) {
return this.http.post(`${this.baseUrl}${instanceId}/receptor_addresses/`, data);
}
deprovisionInstance(instanceId) {
return this.http.patch(`${this.baseUrl}${instanceId}/`, {
node_state: 'deprovisioning',

View File

@ -5,6 +5,10 @@ class ReceptorAddresses extends Base {
super(http);
this.baseUrl = 'api/v2/receptor_addresses/';
}
updateReceptorAddresses(instanceId, data) {
return this.http.post(`${this.baseUrl}`, data);
}
}
export default ReceptorAddresses;

View File

@ -0,0 +1,97 @@
import React from 'react';
import { t } from '@lingui/macro';
import { Form, FormGroup, Modal } from '@patternfly/react-core';
import { InstancesAPI } from 'api';
import { Formik } from 'formik';
import { FormColumnLayout } from 'components/FormLayout';
import FormField, {
CheckboxField,
} from 'components/FormField';
import FormActionGroup from '../FormActionGroup/FormActionGroup';
function AddEndpointModal({
title = t`Add endpoint`,
onClose,
isAddEndpointModalOpen = false,
instance,
ouiaId,
}) {
const handleClose = () => {
onClose();
};
const handleEndpointAdd = async (values) => {
try {
values.id = instance.id;
InstancesAPI.updateReceptorAddresses(instance.id, values);
onClose();
} catch (error) {
// do nothing
}
};
return (
<Modal
ouiaId={ouiaId}
variant="large"
title={title}
aria-label={t`Add Endpoint modal`}
isOpen={isAddEndpointModalOpen}
onClose={handleClose}
actions={[]}
>
<Formik
initialValues={{
listener_port: 1001
}}
onSubmit={handleEndpointAdd}
>
{(formik) => (
<Form autoComplete="off" onSubmit={formik.handleSubmit}>
<FormColumnLayout>
<FormField
id="address"
label={t`Address`}
name="address"
type="text"
/>
<FormField
id="websocket_path"
label={t`Websocket path`}
name="websocket path"
type="text"
/>
<FormField
id="listener_port"
label={t`Listener Port`}
name="listener_port"
type="number"
tooltip={t`Select the port that Receptor will listen on for incoming connections, e.g. 27199.`}
/>
<FormGroup fieldId="endpoint" label={t`Options`}>
<CheckboxField
id="peers_from_control_nodes"
name="peers_from_control_nodes"
label={t`Peers from control nodes`}
tooltip={t`If enabled, control nodes will peer to this instance automatically. If disabled, instance will be connected only to associated peers.`}
/>
</FormGroup>
<FormActionGroup
onCancel={handleClose}
onSubmit={formik.handleSubmit}
/>
</FormColumnLayout>
</Form>
)}
</Formik>
</Modal>
);
}
export default AddEndpointModal;

View File

@ -0,0 +1,84 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import {
mountWithContexts,
waitForElement,
} from '../../../testUtils/enzymeHelpers';
import AssociateModal from './AddEndpointModal';
import mockHosts from './data.hosts.json';
jest.mock('../../api');
describe('<AssociateModal />', () => {
let wrapper;
let onClose;
let onAssociate;
let fetchRequest;
let optionsRequest;
beforeEach(async () => {
onClose = jest.fn();
onAssociate = jest.fn().mockResolvedValue();
fetchRequest = jest.fn().mockReturnValue({ data: { ...mockHosts } });
optionsRequest = jest.fn().mockResolvedValue({
data: {
actions: {
GET: {},
POST: {},
},
related_search_fields: [],
},
});
await act(async () => {
wrapper = mountWithContexts(
<AssociateModal
onClose={onClose}
onAssociate={onAssociate}
fetchRequest={fetchRequest}
optionsRequest={optionsRequest}
isModalOpen
/>
);
});
await waitForElement(wrapper, 'ContentLoading', (el) => el.length === 0);
});
afterEach(() => {
jest.clearAllMocks();
});
test('should render successfully', () => {
expect(wrapper.find('AssociateModal').length).toBe(1);
});
test('should fetch and render list items', () => {
expect(fetchRequest).toHaveBeenCalledTimes(1);
expect(optionsRequest).toHaveBeenCalledTimes(1);
expect(wrapper.find('CheckboxListItem').length).toBe(3);
});
test('should update selected list chips when items are selected', () => {
expect(wrapper.find('SelectedList Chip')).toHaveLength(0);
act(() => {
wrapper.find('CheckboxListItem').first().invoke('onSelect')();
});
wrapper.update();
expect(wrapper.find('SelectedList Chip')).toHaveLength(1);
wrapper.find('SelectedList Chip button').simulate('click');
expect(wrapper.find('SelectedList Chip')).toHaveLength(0);
});
test('save button should call onAssociate', () => {
act(() => {
wrapper.find('CheckboxListItem').first().invoke('onSelect')();
});
wrapper.find('button[aria-label="Save"]').simulate('click');
expect(onAssociate).toHaveBeenCalledTimes(1);
});
test('cancel button should call onClose', () => {
wrapper.find('button[aria-label="Cancel"]').simulate('click');
expect(onClose).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,393 @@
{
"count": 3,
"results": [
{
"id": 2,
"type": "host",
"url": "/api/v2/hosts/2/",
"related": {
"created_by": "/api/v2/users/10/",
"modified_by": "/api/v2/users/19/",
"variable_data": "/api/v2/hosts/2/variable_data/",
"groups": "/api/v2/hosts/2/groups/",
"all_groups": "/api/v2/hosts/2/all_groups/",
"job_events": "/api/v2/hosts/2/job_events/",
"job_host_summaries": "/api/v2/hosts/2/job_host_summaries/",
"activity_stream": "/api/v2/hosts/2/activity_stream/",
"inventory_sources": "/api/v2/hosts/2/inventory_sources/",
"smart_inventories": "/api/v2/hosts/2/smart_inventories/",
"ad_hoc_commands": "/api/v2/hosts/2/ad_hoc_commands/",
"ad_hoc_command_events": "/api/v2/hosts/2/ad_hoc_command_events/",
"insights": "/api/v2/hosts/2/insights/",
"ansible_facts": "/api/v2/hosts/2/ansible_facts/",
"inventory": "/api/v2/inventories/2/",
"last_job": "/api/v2/jobs/236/",
"last_job_host_summary": "/api/v2/job_host_summaries/2202/"
},
"summary_fields": {
"inventory": {
"id": 2,
"name": " Inventory 1 Org 0",
"description": "",
"has_active_failures": false,
"total_hosts": 33,
"hosts_with_active_failures": 0,
"total_groups": 4,
"has_inventory_sources": false,
"total_inventory_sources": 0,
"inventory_sources_with_failures": 0,
"organization_id": 2,
"kind": ""
},
"last_job": {
"id": 236,
"name": " Job Template 1 Project 0",
"description": "",
"finished": "2020-02-26T03:15:21.471439Z",
"status": "successful",
"failed": false,
"job_template_id": 18,
"job_template_name": " Job Template 1 Project 0"
},
"last_job_host_summary": {
"id": 2202,
"failed": false
},
"created_by": {
"id": 10,
"username": "user-3",
"first_name": "",
"last_name": ""
},
"modified_by": {
"id": 19,
"username": "all",
"first_name": "",
"last_name": ""
},
"user_capabilities": {
"edit": true,
"delete": true
},
"groups": {
"count": 2,
"results": [
{
"id": 1,
"name": " Group 1 Inventory 0"
},
{
"id": 2,
"name": " Group 2 Inventory 0"
}
]
},
"recent_jobs": [
{
"id": 236,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-26T03:15:21.471439Z"
},
{
"id": 232,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T21:20:33.593789Z"
},
{
"id": 229,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T16:19:46.364134Z"
},
{
"id": 228,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T16:18:54.138363Z"
},
{
"id": 225,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T15:55:32.247652Z"
}
]
},
"created": "2020-02-24T15:10:58.922179Z",
"modified": "2020-02-26T21:52:43.428530Z",
"name": ".host-000001.group-00000.dummy",
"description": "",
"inventory": 2,
"enabled": false,
"instance_id": "",
"variables": "",
"has_active_failures": false,
"has_inventory_sources": false,
"last_job": 236,
"last_job_host_summary": 2202,
"insights_system_id": null,
"ansible_facts_modified": null
},
{
"id": 3,
"type": "host",
"url": "/api/v2/hosts/3/",
"related": {
"created_by": "/api/v2/users/11/",
"modified_by": "/api/v2/users/1/",
"variable_data": "/api/v2/hosts/3/variable_data/",
"groups": "/api/v2/hosts/3/groups/",
"all_groups": "/api/v2/hosts/3/all_groups/",
"job_events": "/api/v2/hosts/3/job_events/",
"job_host_summaries": "/api/v2/hosts/3/job_host_summaries/",
"activity_stream": "/api/v2/hosts/3/activity_stream/",
"inventory_sources": "/api/v2/hosts/3/inventory_sources/",
"smart_inventories": "/api/v2/hosts/3/smart_inventories/",
"ad_hoc_commands": "/api/v2/hosts/3/ad_hoc_commands/",
"ad_hoc_command_events": "/api/v2/hosts/3/ad_hoc_command_events/",
"insights": "/api/v2/hosts/3/insights/",
"ansible_facts": "/api/v2/hosts/3/ansible_facts/",
"inventory": "/api/v2/inventories/2/",
"last_job": "/api/v2/jobs/236/",
"last_job_host_summary": "/api/v2/job_host_summaries/2195/"
},
"summary_fields": {
"inventory": {
"id": 2,
"name": " Inventory 1 Org 0",
"description": "",
"has_active_failures": false,
"total_hosts": 33,
"hosts_with_active_failures": 0,
"total_groups": 4,
"has_inventory_sources": false,
"total_inventory_sources": 0,
"inventory_sources_with_failures": 0,
"organization_id": 2,
"kind": ""
},
"last_job": {
"id": 236,
"name": " Job Template 1 Project 0",
"description": "",
"finished": "2020-02-26T03:15:21.471439Z",
"status": "successful",
"failed": false,
"job_template_id": 18,
"job_template_name": " Job Template 1 Project 0"
},
"last_job_host_summary": {
"id": 2195,
"failed": false
},
"created_by": {
"id": 11,
"username": "user-4",
"first_name": "",
"last_name": ""
},
"modified_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"user_capabilities": {
"edit": true,
"delete": true
},
"groups": {
"count": 2,
"results": [
{
"id": 1,
"name": " Group 1 Inventory 0"
},
{
"id": 2,
"name": " Group 2 Inventory 0"
}
]
},
"recent_jobs": [
{
"id": 236,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-26T03:15:21.471439Z"
},
{
"id": 232,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T21:20:33.593789Z"
},
{
"id": 229,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T16:19:46.364134Z"
},
{
"id": 228,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T16:18:54.138363Z"
},
{
"id": 225,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T15:55:32.247652Z"
}
]
},
"created": "2020-02-24T15:10:58.945113Z",
"modified": "2020-02-27T03:43:43.635871Z",
"name": ".host-000002.group-00000.dummy",
"description": "",
"inventory": 2,
"enabled": false,
"instance_id": "",
"variables": "",
"has_active_failures": false,
"has_inventory_sources": false,
"last_job": 236,
"last_job_host_summary": 2195,
"insights_system_id": null,
"ansible_facts_modified": null
},
{
"id": 4,
"type": "host",
"url": "/api/v2/hosts/4/",
"related": {
"created_by": "/api/v2/users/12/",
"modified_by": "/api/v2/users/1/",
"variable_data": "/api/v2/hosts/4/variable_data/",
"groups": "/api/v2/hosts/4/groups/",
"all_groups": "/api/v2/hosts/4/all_groups/",
"job_events": "/api/v2/hosts/4/job_events/",
"job_host_summaries": "/api/v2/hosts/4/job_host_summaries/",
"activity_stream": "/api/v2/hosts/4/activity_stream/",
"inventory_sources": "/api/v2/hosts/4/inventory_sources/",
"smart_inventories": "/api/v2/hosts/4/smart_inventories/",
"ad_hoc_commands": "/api/v2/hosts/4/ad_hoc_commands/",
"ad_hoc_command_events": "/api/v2/hosts/4/ad_hoc_command_events/",
"insights": "/api/v2/hosts/4/insights/",
"ansible_facts": "/api/v2/hosts/4/ansible_facts/",
"inventory": "/api/v2/inventories/2/",
"last_job": "/api/v2/jobs/236/",
"last_job_host_summary": "/api/v2/job_host_summaries/2192/"
},
"summary_fields": {
"inventory": {
"id": 2,
"name": " Inventory 1 Org 0",
"description": "",
"has_active_failures": false,
"total_hosts": 33,
"hosts_with_active_failures": 0,
"total_groups": 4,
"has_inventory_sources": false,
"total_inventory_sources": 0,
"inventory_sources_with_failures": 0,
"organization_id": 2,
"kind": ""
},
"last_job": {
"id": 236,
"name": " Job Template 1 Project 0",
"description": "",
"finished": "2020-02-26T03:15:21.471439Z",
"status": "successful",
"failed": false,
"job_template_id": 18,
"job_template_name": " Job Template 1 Project 0"
},
"last_job_host_summary": {
"id": 2192,
"failed": false
},
"created_by": {
"id": 12,
"username": "user-5",
"first_name": "",
"last_name": ""
},
"modified_by": {
"id": 1,
"username": "admin",
"first_name": "",
"last_name": ""
},
"user_capabilities": {
"edit": true,
"delete": true
},
"groups": {
"count": 2,
"results": [
{
"id": 1,
"name": " Group 1 Inventory 0"
},
{
"id": 2,
"name": " Group 2 Inventory 0"
}
]
},
"recent_jobs": [
{
"id": 236,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-26T03:15:21.471439Z"
},
{
"id": 232,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T21:20:33.593789Z"
},
{
"id": 229,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T16:19:46.364134Z"
},
{
"id": 228,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T16:18:54.138363Z"
},
{
"id": 225,
"name": " Job Template 1 Project 0",
"status": "successful",
"finished": "2020-02-25T15:55:32.247652Z"
}
]
},
"created": "2020-02-24T15:10:58.962312Z",
"modified": "2020-02-27T03:43:45.528882Z",
"name": ".host-000003.group-00000.dummy",
"description": "",
"inventory": 2,
"enabled": false,
"instance_id": "",
"variables": "",
"has_active_failures": false,
"has_inventory_sources": false,
"last_job": 236,
"last_job_host_summary": 2192,
"insights_system_id": null,
"ansible_facts_modified": null
}
]
}

View File

@ -0,0 +1 @@
export { default } from './AddEndpointModal';

View File

@ -12,6 +12,7 @@ import { SettingsAPI } from 'api';
import ContentLoading from 'components/ContentLoading';
import InstanceDetail from './InstanceDetail';
import InstancePeerList from './InstancePeers';
import InstanceEndPointList from './InstanceEndPointList';
function Instance({ setBreadcrumb }) {
const { me } = useConfig();
@ -54,7 +55,8 @@ function Instance({ setBreadcrumb }) {
}, [request]);
if (isK8s) {
tabsArray.push({ name: t`Peers`, link: `${match.url}/peers`, id: 1 });
tabsArray.push({ name: t`Endpoints`, link: `${match.url}/endpoints`, id: 1 });
tabsArray.push({ name: t`Peers`, link: `${match.url}/peers`, id: 2 });
}
if (isLoading) {
return <ContentLoading />;
@ -72,6 +74,11 @@ function Instance({ setBreadcrumb }) {
<Route path="/instances/:id/details" key="details">
<InstanceDetail isK8s={isK8s} setBreadcrumb={setBreadcrumb} />
</Route>
{isK8s && (
<Route path="/instances/:id/endpoints" key="endpoints">
<InstanceEndPointList setBreadcrumb={setBreadcrumb} />
</Route>
)}
{isK8s && (
<Route path="/instances/:id/peers" key="peers">
<InstancePeerList setBreadcrumb={setBreadcrumb} />

View File

@ -0,0 +1,188 @@
import React, { useCallback, useEffect, useState } from 'react';
import { t } from '@lingui/macro';
import { CardBody } from 'components/Card';
import PaginatedTable, {
getSearchableKeys,
HeaderCell,
HeaderRow,
ToolbarAddButton,
} from 'components/PaginatedTable';
import AddEndpointModal from 'components/AddEndpointModal';
import useToast from 'hooks/useToast';
import { getQSConfig } from 'util/qs';
import { useParams } from 'react-router-dom';
import useRequest from 'hooks/useRequest';
import DataListToolbar from 'components/DataListToolbar';
import { InstancesAPI, ReceptorAPI } from 'api';
import useExpanded from 'hooks/useExpanded';
import useSelected from 'hooks/useSelected';
import InstanceEndPointListItem from './InstanceEndPointListItem';
const QS_CONFIG = getQSConfig('peer', {
page: 1,
page_size: 20,
order_by: 'pk',
});
function InstanceEndPointList({ setBreadcrumb }) {
const { id } = useParams();
const [isAddEndpointModalOpen, setisAddEndpointModalOpen] = useState(false);
const { Toast, toastProps } = useToast();
const {
isLoading,
error: contentError,
request: fetchEndpoints,
result: { instance, endpoints, count, relatedSearchableKeys, searchableKeys },
} = useRequest(
useCallback(async () => {
const [
{ data: detail },
{
data: { results },
},
actions,
] = await Promise.all([
InstancesAPI.readDetail(id),
ReceptorAPI.read(),
InstancesAPI.readOptions(),
]);
const endpoint_list = []
for(let q = 0; q < results.length; q++) {
const receptor = results[q];
if(id.toString() === receptor.instance.toString()) {
endpoint_list.push(receptor);
}
}
return {
instance: detail,
endpoints: endpoint_list,
count: endpoint_list.length,
relatedSearchableKeys: (actions?.data?.related_search_fields || []).map(
(val) => val.slice(0, -8)
),
searchableKeys: getSearchableKeys(actions.data.actions?.GET),
};
}, [id]),
{
instance: {},
endpoints: [],
count: 0,
relatedSearchableKeys: [],
searchableKeys: [],
}
);
useEffect(() => {
fetchEndpoints();
}, [fetchEndpoints]);
useEffect(() => {
if (instance) {
setBreadcrumb(instance);
}
}, [instance, setBreadcrumb]);
const { expanded, isAllExpanded, handleExpand, expandAll } =
useExpanded(endpoints);
const { selected, isAllSelected, handleSelect, clearSelected, selectAll } =
useSelected(endpoints);
const handleEndpointDelete = async () => {
// console.log(selected)
// InstancesAPI.updateReceptorAddresses(instance.id, values);
}
const isHopNode = instance.node_type === 'hop';
const isExecutionNode = instance.node_type === 'execution';
return (
<CardBody>
<PaginatedTable
contentError={contentError}
hasContentLoading={
isLoading
}
items={endpoints}
itemCount={count}
pluralizedItemName={t`Endpoints`}
qsConfig={QS_CONFIG}
onRowClick={handleSelect}
clearSelected={clearSelected}
toolbarSearchableKeys={searchableKeys}
toolbarRelatedSearchableKeys={relatedSearchableKeys}
toolbarSearchColumns={[
{
name: t`Name`,
key: 'hostname__icontains',
isDefault: true,
},
]}
toolbarSortColumns={[
{
name: t`Name`,
key: 'hostname',
},
]}
headerRow={
<HeaderRow qsConfig={QS_CONFIG} isExpandable>
<HeaderCell sortKey="address">{t`Address`}</HeaderCell>
<HeaderCell sortKey="port">{t`Port`}</HeaderCell>
</HeaderRow>
}
renderToolbar={(props) => (
<DataListToolbar
{...props}
isAllSelected={isAllSelected}
onSelectAll={selectAll}
isAllExpanded={isAllExpanded}
onExpandAll={expandAll}
qsConfig={QS_CONFIG}
additionalControls={[
(isExecutionNode || isHopNode) && (
<ToolbarAddButton
ouiaId="add-endpoint-button"
key="add-endpoint"
defaultLabel={t`Add`}
onClick={() => setisAddEndpointModalOpen(true)}
/>
),
(isExecutionNode || isHopNode) && (
<ToolbarAddButton
ouiaId="delete-endpoint-button"
key="delete-endpoint"
defaultLabel={t`Delete`}
onClick={() => handleEndpointDelete()}
/>
),
]}
/>
)}
renderRow={(endpoint, index) => (
<InstanceEndPointListItem
isSelected={selected.some((row) => row.id === endpoint.id)}
onSelect={() => handleSelect(endpoint)}
isExpanded={expanded.some((row) => row.id === endpoint.id)}
onExpand={() => handleExpand(endpoint)}
key={endpoint.id}
peerInstance={endpoint}
rowIndex={index}
/>
)}
/>
{isAddEndpointModalOpen && (
<AddEndpointModal
isAddEndpointModalOpen={isAddEndpointModalOpen}
onClose={() => setisAddEndpointModalOpen(false)}
title={t`New endpoint`}
instance={instance}
/>
)}
<Toast {...toastProps} />
</CardBody>
);
}
export default InstanceEndPointList;

View File

@ -0,0 +1,54 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { t } from '@lingui/macro';
import 'styled-components/macro';
import { Tr, Td } from '@patternfly/react-table';
function InstanceEndPointListItem({
peerInstance,
isSelected,
onSelect,
isExpanded,
onExpand,
rowIndex,
}) {
const labelId = `check-action-${peerInstance.id}`;
return (
<Tr
id={`peerInstance-row-${peerInstance.id}`}
ouiaId={`peerInstance-row-${peerInstance.id}`}
>
<Td
expand={{
rowIndex,
isExpanded,
onToggle: onExpand,
}}
/>
<Td
select={{
rowIndex,
isSelected,
onSelect,
}}
dataLabel={t`Selected`}
/>
<Td id={labelId} dataLabel={t`Address`}>
<Link to={`/instances/${peerInstance.instance}/details`}>
<b>{peerInstance.address}</b>
</Link>
</Td>
<Td id={labelId} dataLabel={t`Port`}>
<Link to={`/instances/${peerInstance.instance}/details`}>
<b>{peerInstance.port}</b>
</Link>
</Td>
</Tr>
);
}
export default InstanceEndPointListItem;

View File

@ -0,0 +1 @@
export { default } from './InstanceEndPointList';

View File

@ -47,7 +47,7 @@ function InstancePeerList({ setBreadcrumb }) {
const [
{ data: detail },
{
data: { results, count: itemNumber },
data: { results },
},
actions,
instances,
@ -72,7 +72,7 @@ function InstancePeerList({ setBreadcrumb }) {
return {
instance: detail,
peers: address_list,
count: itemNumber,
count: address_list.length,
relatedSearchableKeys: (actions?.data?.related_search_fields || []).map(
(val) => val.slice(0, -8)
),
@ -283,7 +283,7 @@ function InstancePeerList({ setBreadcrumb }) {
key="disassociate"
onDisassociate={handlePeersDiassociate}
itemsToDisassociate={selected}
modalTitle={t`Remove instance from peers?`}
modalTitle={t`Remove peers?`}
/>
),
]}

View File

@ -43,7 +43,6 @@ function InstancePeerListItem({
}}
dataLabel={t`Selected`}
/>
<Td id={labelId} dataLabel={t`Address`}>
<Link to={`/instances/${peerInstance.instance}/details`}>
<b>{peerInstance.address}</b>

View File

@ -25,6 +25,7 @@ function Instances() {
[`/instances/${instance.id}`]: `${instance.hostname}`,
[`/instances/${instance.id}/details`]: t`Details`,
[`/instances/${instance.id}/peers`]: t`Peers`,
[`/instances/${instance.id}/endpoints`]: t`Endpoints`,
[`/instances/${instance.id}/edit`]: t`Edit Instance`,
});
}, []);