mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 15:04:52 +09:00
Link to an approval dialog from the waiting host
A host waiting for approval logs a link to the Hosts page with the code in it. Opening it shows a dialog with the code, the address of the host, a name prefilled with the requested one and a large Approve button. A code that is no longer waiting shows a message saying so.
This commit is contained in:
4 files changed
+143
-9
No files matched your search
@@ -276,9 +276,11 @@ Run the installer as described in the installation section and choose "Host only
|
||||
The URL is the same as what is needed to connect to the web interface.
|
||||
Then start the host, the first time it starts it prints a code and waits for the host to be approved:
|
||||
|
||||
Waiting for approval. Approve "my-host" with code ABC-DEF on the Hosts page of https://example.com/, or run: npx clusterioctl host approve ABC-DEF
|
||||
Waiting for approval of "my-host" with code ABC-DEF, open this link to approve it:
|
||||
https://example.com/hosts?approve=ABC-DEF
|
||||
Or run: npx clusterioctl host approve ABC-DEF
|
||||
|
||||
Approve it in the web interface by clicking Add host on the Hosts page and approving the request with the same code, or with `npx clusterioctl host approve <code>`.
|
||||
Open the link to approve it in the web interface, where you can also change its name, or click Add host on the Hosts page and approve the request with the same code, or run `npx clusterioctl host approve <code>`.
|
||||
The host then saves its connection details and starts up, it should show up in the Hosts list and be available for assigning and running instances on.
|
||||
Only approve a request if its code matches the one shown by the host, approving gives the host full access to the cluster.
|
||||
Approving requires the `core.host.approve` permission, which the Cluster Admin role has.
|
||||
|
||||
@@ -59,9 +59,12 @@ A new host has to be approved before it can join the cluster:
|
||||
2. Start the host with `run-host.cmd`, `run-host.sh` or the systemd service in the `systemd` folder.
|
||||
The first time it starts it prints a code and waits for the host to be approved:
|
||||
|
||||
Waiting for approval. Approve "my-host" with code ABC-DEF on the Hosts page of https://example.com/, or run: npx clusterioctl host approve ABC-DEF
|
||||
Waiting for approval of "my-host" with code ABC-DEF, open this link to approve it:
|
||||
https://example.com/hosts?approve=ABC-DEF
|
||||
Or run: npx clusterioctl host approve ABC-DEF
|
||||
|
||||
3. Approve the request by clicking Add host on the Hosts page of the web interface, or run `npx clusterioctl host approve ABC-DEF`.
|
||||
3. Open the link to approve the request in the web interface, where its name can be changed first.
|
||||
It can also be approved under Add host on the Hosts page, or with `npx clusterioctl host approve ABC-DEF`.
|
||||
Only approve a request if its code matches the one shown by the host.
|
||||
Approving requires the `core.host.approve` permission.
|
||||
|
||||
|
||||
@@ -275,10 +275,11 @@ export async function ensurePaired(
|
||||
token: options.userToken,
|
||||
signal: options.signal,
|
||||
onPending: code => {
|
||||
logger.info(
|
||||
`Waiting for approval. Approve "${name}" with code ${code} on the Hosts page of ` +
|
||||
`${controllerUrl}, or run: npx clusterioctl host approve ${code}`
|
||||
);
|
||||
const approveUrl = new URL(`hosts?approve=${code}`, controllerUrl).href;
|
||||
// The link is logged on its own so terminals can make it clickable without it wrapping.
|
||||
logger.info(`Waiting for approval of "${name}" with code ${code}, open this link to approve it:`);
|
||||
logger.info(approveUrl);
|
||||
logger.info(`Or run: npx clusterioctl host approve ${code}`);
|
||||
},
|
||||
onRetry: (err, delayMs) => {
|
||||
logger.warn(`Requesting to join failed: ${err.message}, trying again in ${Math.ceil(delayMs / 1000)}s`);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import React, { useContext, useState, useEffect } from "react";
|
||||
import { Badge, Button, Flex, Input, Modal, Space, Switch, Table, Tooltip, Typography } from "antd";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
Badge, Button, Descriptions, Flex, Input, Modal, Result, Space, Spin, Switch, Table, Tooltip, Typography,
|
||||
} from "antd";
|
||||
import { ExclamationCircleOutlined, CopyOutlined, PlusOutlined } from "@ant-design/icons";
|
||||
|
||||
import * as lib from "@clusterio/lib";
|
||||
@@ -200,6 +203,120 @@ function AddHostModal(props: { open: boolean, onClose: () => void }) {
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
/** Format a code like the controller does, matching any case and without the dash */
|
||||
function normaliseCode(code: string) {
|
||||
const plain = code.toUpperCase().replace(/[^0-9A-Z]/g, "");
|
||||
return `${plain.slice(0, 3)}-${plain.slice(3)}`;
|
||||
}
|
||||
|
||||
/** Dialog opened by the link a waiting host prints, for approving that host */
|
||||
function ApproveHostModal(props: { code: string, onClose: () => void }) {
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const [requests, synced] = useHostPairings();
|
||||
const code = normaliseCode(props.code);
|
||||
const request = requests.get(code);
|
||||
const [name, setName] = useState<string>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const now = useNow(true);
|
||||
const permitted = account.hasPermission("core.host.approve");
|
||||
const hostName = (name ?? request?.name ?? "").trim();
|
||||
|
||||
function approve() {
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
control.send(
|
||||
new lib.HostPairingApproveRequest(request.code, hostName !== request.name ? hostName : undefined)
|
||||
).then(() => {
|
||||
notify(`Approved host ${hostName}`, "success");
|
||||
props.onClose();
|
||||
}).catch(
|
||||
notifyErrorHandler(`Error approving host ${request.name}`)
|
||||
).finally(() => {
|
||||
setBusy(false);
|
||||
});
|
||||
}
|
||||
|
||||
function reject() {
|
||||
if (!request) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
control.send(new lib.HostPairingRejectRequest(request.code)).then(() => {
|
||||
notify(`Rejected host ${request.name}`);
|
||||
props.onClose();
|
||||
}).catch(
|
||||
notifyErrorHandler(`Error rejecting host ${request.name}`)
|
||||
).finally(() => {
|
||||
setBusy(false);
|
||||
});
|
||||
}
|
||||
|
||||
let content;
|
||||
if (!permitted) {
|
||||
content = <Result
|
||||
status="403"
|
||||
title="You are not allowed to approve hosts"
|
||||
subTitle="Ask someone with the Approve hosts permission to open this link."
|
||||
/>;
|
||||
} else if (!request && !synced) {
|
||||
content = <Flex justify="center" style={{ padding: 48 }}><Spin size="large" /></Flex>;
|
||||
} else if (!request) {
|
||||
content = <Result
|
||||
status="warning"
|
||||
title={<>No host is waiting with code <span style={{ whiteSpace: "nowrap" }}>{code}</span></>}
|
||||
subTitle={
|
||||
"It may have been approved or rejected already, or stopped waiting. " +
|
||||
"A host that is still waiting shows a new code."
|
||||
}
|
||||
/>;
|
||||
} else {
|
||||
content = <>
|
||||
<Typography.Paragraph>
|
||||
A host is asking to join the cluster.
|
||||
Only approve it if the code below matches the code shown by the host,
|
||||
an approved host has full access to the cluster.
|
||||
</Typography.Paragraph>
|
||||
<Flex justify="center" style={{ margin: "24px 0" }}>
|
||||
<Typography.Text code strong style={{ fontSize: 32, letterSpacing: 4 }}>{request.code}</Typography.Text>
|
||||
</Flex>
|
||||
<Descriptions size="small" column={1} bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="Address">{request.remoteAddress}</Descriptions.Item>
|
||||
{request.version && <Descriptions.Item label="Version">{request.version}</Descriptions.Item>}
|
||||
<Descriptions.Item label="Waiting for">
|
||||
{formatDuration(Math.max(0, now - request.createdAtMs))}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Text strong>Name</Typography.Text>
|
||||
<Input
|
||||
size="large"
|
||||
aria-label="Host name"
|
||||
value={name ?? request.name}
|
||||
onChange={event => { setName(event.target.value); }}
|
||||
onPressEnter={approve}
|
||||
style={{ margin: "4px 0 24px" }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
block
|
||||
disabled={busy || !hostName}
|
||||
loading={busy}
|
||||
onClick={approve}
|
||||
>Approve</Button>
|
||||
<Flex justify="center" style={{ marginTop: 12 }}>
|
||||
<Button type="link" danger disabled={busy} onClick={reject}>Reject</Button>
|
||||
</Flex>
|
||||
</>;
|
||||
}
|
||||
|
||||
return <Modal title="Approve host" open footer={null} onCancel={props.onClose} width="520px">
|
||||
{content}
|
||||
</Modal>;
|
||||
}
|
||||
|
||||
function AddHostButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [requests] = useHostPairings();
|
||||
@@ -266,8 +383,19 @@ export default function HostsPage() {
|
||||
});
|
||||
const nameSearch = useColumnSearch<lib.HostDetails>(tableState, "name", host => host.name, "Search hosts");
|
||||
const rowNav = useRowNavigation();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const approveCode = params.get("approve");
|
||||
|
||||
function closeApprove() {
|
||||
setParams(current => {
|
||||
const next = new URLSearchParams(current);
|
||||
next.delete("approve");
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}
|
||||
|
||||
return <PageLayout nav={[{ name: "Hosts" }]}>
|
||||
{approveCode !== null && <ApproveHostModal code={approveCode} onClose={closeApprove} />}
|
||||
<PageHeader
|
||||
title="Hosts"
|
||||
extra={account.hasPermission("core.host.approve") ? <AddHostButton /> : undefined}
|
||||
|
||||
Reference in new issue
Block a user