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:
Danielv123 committed 2026-10-30 21:23:03 +01:00
1 parent 4a49a198f0
commit 13c75b9e35
4 files changed
+143 -9

No files matched your search

+4 -2
View File
@@ -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.
+5 -2
View File
@@ -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.
+5 -4
View File
@@ -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`);
+129 -1
View File
@@ -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}