364 lines
17 KiB
TypeScript
364 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef, useState } from "react";
|
||
import { PairingCancelButton } from "../PairingCancelButton";
|
||
import {
|
||
buildDaemonRegistrationCommand,
|
||
daemonStartCommand,
|
||
type SupportedHostOS,
|
||
} from "./onboarding";
|
||
import type { PairingAccessState } from "../../onboarding";
|
||
|
||
type PairingResult = { id: string; bootstrapToken: string; expiresAt: string };
|
||
type PairingView = PairingResult & {
|
||
requestedName: string;
|
||
os: SupportedHostOS;
|
||
};
|
||
type PairingProgress = {
|
||
id: string;
|
||
status: "waiting" | "claimed" | "expired" | "locked" | "cancelled";
|
||
expiresAt: string;
|
||
claimedHostId: string | null;
|
||
claimedAt: string | null;
|
||
claimAttemptState: "not_seen" | "seen" | "invalid";
|
||
lastClaimAttemptAt: string | null;
|
||
};
|
||
type PairingTerminal = Exclude<PairingProgress["status"], "waiting" | "claimed">;
|
||
type PairingCompletion = {
|
||
hostId: string;
|
||
claimedAt: string;
|
||
};
|
||
type CopyTarget = "command" | "token" | "start";
|
||
const unavailableCopy: Record<Exclude<PairingAccessState, "available">, { title: string; detail: string; href: string; action: string; readiness?: boolean }> = {
|
||
request_pending: {
|
||
title: "免费闭测申请正在审核",
|
||
detail: "处理结果会显示在公测权益页;审核期间不需要重复申请。",
|
||
href: "/dashboard/billing",
|
||
action: "查看申请进度",
|
||
},
|
||
gated: {
|
||
title: "公开接入仍由安全门禁阻止",
|
||
detail: "免费政策已经预设,但 P0 证据尚未齐全。可以申请小范围免费闭测资格。",
|
||
href: "/dashboard/billing",
|
||
action: "申请免费闭测",
|
||
readiness: true,
|
||
},
|
||
inactive: {
|
||
title: "公开公测当前未开放",
|
||
detail: "该账户没有有效的免费公测或闭测邀请;可以提交闭测申请,不会因此创建订单或要求付款。",
|
||
href: "/dashboard/billing",
|
||
action: "申请免费闭测",
|
||
},
|
||
full: {
|
||
title: "当前没有可用主机槽位",
|
||
detail: "已有主机和等待中的配对请求已经占满当前免费容量,请先取消旧请求或联系管理员调整邀请。",
|
||
href: "/dashboard/hosts",
|
||
action: "管理主机和配对",
|
||
},
|
||
};
|
||
const terminalCopy: Record<PairingTerminal, { title: string; detail: string }> = {
|
||
expired: {
|
||
title: "这枚配对码已经过期",
|
||
detail: "十分钟有效期已经结束,原码不能恢复。确认 daemon 已准备好后再生成一枚新码。",
|
||
},
|
||
locked: {
|
||
title: "这枚配对码已经锁定",
|
||
detail: "错误尝试次数已达到上限,原码不能继续使用。请核对 daemon 和复制步骤后重新生成。",
|
||
},
|
||
cancelled: {
|
||
title: "这枚配对码已经取消",
|
||
detail: "原码已失效并释放预留容量;需要接入时可以重新生成。",
|
||
},
|
||
};
|
||
|
||
export function PairingForm({
|
||
accessState,
|
||
releaseAvailable,
|
||
minimumDaemonVersion,
|
||
connectOrigin,
|
||
}: {
|
||
accessState: PairingAccessState;
|
||
releaseAvailable: boolean;
|
||
minimumDaemonVersion: string;
|
||
connectOrigin: string;
|
||
}) {
|
||
const [name, setName] = useState("");
|
||
const [os, setOs] = useState<SupportedHostOS>("windows");
|
||
const [pairing, setPairing] = useState<PairingView | null>(null);
|
||
const [error, setError] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [copied, setCopied] = useState<CopyTarget | null>(null);
|
||
const [copyError, setCopyError] = useState("");
|
||
const [closedBetaBuildConfirmed, setClosedBetaBuildConfirmed] = useState(false);
|
||
const [progressError, setProgressError] = useState("");
|
||
const [completion, setCompletion] = useState<PairingCompletion | null>(null);
|
||
const [terminal, setTerminal] = useState<PairingTerminal | null>(null);
|
||
const [claimAttempt, setClaimAttempt] = useState<Pick<
|
||
PairingProgress,
|
||
"claimAttemptState" | "lastClaimAttemptAt"
|
||
>>({ claimAttemptState: "not_seen", lastClaimAttemptAt: null });
|
||
const resultHeading = useRef<HTMLHeadingElement>(null);
|
||
const pairingId = pairing?.id ?? null;
|
||
|
||
useEffect(() => {
|
||
if (pairing) resultHeading.current?.focus();
|
||
}, [pairing]);
|
||
|
||
useEffect(() => {
|
||
if (!pairingId) return;
|
||
let active = true;
|
||
let timer: number | undefined;
|
||
|
||
async function poll() {
|
||
try {
|
||
const response = await fetch(
|
||
`/api/hosts/pairing?pairing_id=${encodeURIComponent(pairingId!)}`,
|
||
{ cache: "no-store" },
|
||
);
|
||
const body = (await response.json()) as {
|
||
pairing?: PairingProgress;
|
||
message?: string;
|
||
};
|
||
if (!active) return;
|
||
if (!response.ok || !body.pairing) {
|
||
throw new Error(body.message || "暂时无法确认配对状态");
|
||
}
|
||
setProgressError("");
|
||
setClaimAttempt({
|
||
claimAttemptState: body.pairing.claimAttemptState,
|
||
lastClaimAttemptAt: body.pairing.lastClaimAttemptAt,
|
||
});
|
||
if (body.pairing.status === "claimed" && body.pairing.claimedHostId && body.pairing.claimedAt) {
|
||
setCompletion({
|
||
hostId: body.pairing.claimedHostId,
|
||
claimedAt: body.pairing.claimedAt,
|
||
});
|
||
setPairing(null);
|
||
setCopied(null);
|
||
setCopyError("");
|
||
return;
|
||
}
|
||
if (
|
||
body.pairing.status === "expired"
|
||
|| body.pairing.status === "locked"
|
||
|| body.pairing.status === "cancelled"
|
||
) {
|
||
setTerminal(body.pairing.status);
|
||
setPairing(null);
|
||
setCopied(null);
|
||
setCopyError("");
|
||
return;
|
||
}
|
||
timer = window.setTimeout(poll, 2_000);
|
||
} catch (pollError) {
|
||
if (!active) return;
|
||
setProgressError(
|
||
pollError instanceof Error
|
||
? `${pollError.message};页面会继续重试。`
|
||
: "暂时无法确认配对状态;页面会继续重试。",
|
||
);
|
||
timer = window.setTimeout(poll, 5_000);
|
||
}
|
||
}
|
||
|
||
timer = window.setTimeout(poll, 1_000);
|
||
return () => {
|
||
active = false;
|
||
if (timer !== undefined) window.clearTimeout(timer);
|
||
};
|
||
}, [pairingId]);
|
||
|
||
if (accessState !== "available") {
|
||
const copy = unavailableCopy[accessState];
|
||
return (
|
||
<div className="empty-state pairing-access-blocked" role="status">
|
||
<span className="empty-symbol">×</span>
|
||
<h2>{copy.title}</h2>
|
||
<p>{copy.detail}</p>
|
||
<div className="pairing-result-actions">
|
||
<a className="button button-primary" href={copy.href}>{copy.action}</a>
|
||
{copy.readiness && <a className="button button-secondary" href="/readiness">查看公测门禁</a>}
|
||
<a className="button button-secondary" href="/dashboard">返回总览</a>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
async function copyText(value: string, target: CopyTarget) {
|
||
setCopyError("");
|
||
try {
|
||
if (!navigator.clipboard?.writeText) throw new Error("clipboard_unavailable");
|
||
await navigator.clipboard.writeText(value);
|
||
setCopied(target);
|
||
} catch {
|
||
setCopied(null);
|
||
setCopyError("浏览器未允许自动复制。请点入对应文本框,使用 Ctrl+C 或系统复制操作手动复制。");
|
||
}
|
||
}
|
||
|
||
async function submit(event: React.FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
setError("");
|
||
setTerminal(null);
|
||
setCompletion(null);
|
||
if (!releaseAvailable && !closedBetaBuildConfirmed) {
|
||
setError("公开下载尚未就绪。请先取得并核验兼容的闭测构建,再确认后生成配对码。");
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
const response = await fetch("/api/hosts/pairing", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ requestedName: name, os }),
|
||
});
|
||
const body = (await response.json()) as { pairing?: PairingResult; message?: string };
|
||
if (!response.ok || !body.pairing) throw new Error(body.message || "无法创建配对请求");
|
||
setPairing({
|
||
...body.pairing,
|
||
requestedName: name.trim(),
|
||
os,
|
||
});
|
||
setCopied(null);
|
||
setCopyError("");
|
||
setProgressError("");
|
||
setClaimAttempt({ claimAttemptState: "not_seen", lastClaimAttemptAt: null });
|
||
} catch (submitError) {
|
||
setError(submitError instanceof Error ? submitError.message : "无法创建配对请求");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
if (completion) {
|
||
return (
|
||
<div className="empty-state pairing-complete" role="status">
|
||
<span className="empty-symbol">✓</span>
|
||
<h2>主机已成功认领</h2>
|
||
<p>一次性配对码已经从页面状态中清除。控制平面已建立可撤销设备凭据并推进授权 revision;daemon 会继续连接同一个服务地址。</p>
|
||
<small>认领时间:{new Date(completion.claimedAt).toLocaleString("zh-CN")} · 主机编号 {completion.hostId}</small>
|
||
<div className="pairing-result-actions">
|
||
<a className="button button-primary" href="/dashboard/hosts">查看主机与开通状态</a>
|
||
<button className="button button-secondary" type="button" onClick={() => setCompletion(null)}>再添加一台主机</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (terminal) {
|
||
const copy = terminalCopy[terminal];
|
||
return (
|
||
<div className="empty-state pairing-terminal" role="status">
|
||
<span className="empty-symbol">×</span>
|
||
<h2>{copy.title}</h2>
|
||
<p>{copy.detail}</p>
|
||
<div className="pairing-result-actions">
|
||
<button className="button button-primary" type="button" onClick={() => setTerminal(null)}>重新生成配对码</button>
|
||
<a className="button button-secondary" href="/dashboard/hosts">查看主机和配对</a>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (pairing) {
|
||
const registrationCommand = buildDaemonRegistrationCommand({
|
||
os: pairing.os,
|
||
connectOrigin,
|
||
hostName: pairing.requestedName,
|
||
});
|
||
const startCommand = daemonStartCommand(pairing.os);
|
||
return (
|
||
<div className="pairing-result">
|
||
<span className="eyebrow">PAIRING REQUEST CREATED</span>
|
||
<h2 ref={resultHeading} tabIndex={-1}>在 {pairing.os === "windows" ? "Windows" : "Linux"} 主机完成注册</h2>
|
||
<p>命令已按 Cloud 稳定 Connect 服务地址和主机名生成。一次性码不会拼进命令、URL 或浏览器存储。</p>
|
||
<div className="pairing-progress" role="status" aria-live="polite">
|
||
<strong>{claimAttempt.claimAttemptState === "seen" ? "Cloud 已收到请求,但尚未认领" : claimAttempt.claimAttemptState === "invalid" ? "认领尝试时间待核实" : "尚未收到注册请求"}</strong>
|
||
<span>{claimAttempt.claimAttemptState === "seen"
|
||
? `最近一次尝试:${new Date(claimAttempt.lastClaimAttemptAt!).toLocaleString("zh-CN")}。这只证明 Cloud 收到过针对该配对 ID 的请求,不证明来源一定是你的 daemon。请先查看终端错误,并核对系统类型、配对码、daemon 版本和 identity.json;当前码有效时无需反复生成。`
|
||
: claimAttempt.claimAttemptState === "invalid"
|
||
? "Cloud 找到过针对这枚配对请求的记录,但时间异常,不能判断先后;请查看终端错误或提交反馈。"
|
||
: "请先在目标主机运行注册命令并粘贴一次性码。页面每两秒确认一次,尚未收到请求通常表示命令未运行、地址不可达或请求还没发出。"}</span>
|
||
</div>
|
||
{progressError && <p className="form-error" role="alert">{progressError}</p>}
|
||
<a className="text-link pairing-download-link" href="/download">还没有兼容 daemon?先查看下载与 SHA-256 校验 →</a>
|
||
|
||
<ol className="registration-steps">
|
||
<li>
|
||
<strong>复制命令,在 daemon 所在目录运行</strong>
|
||
<label className="pairing-copy-field">
|
||
<span>{pairing.os === "windows" ? "PowerShell" : "Bash"} 注册命令</span>
|
||
<textarea
|
||
readOnly
|
||
rows={pairing.os === "windows" ? 12 : 10}
|
||
value={registrationCommand}
|
||
onFocus={(event) => event.currentTarget.select()}
|
||
aria-describedby="registration-command-note"
|
||
/>
|
||
</label>
|
||
<button className="button button-secondary" type="button" onClick={() => copyText(registrationCommand, "command")}>{copied === "command" ? "命令已复制" : "复制注册命令"}</button>
|
||
<small id="registration-command-note">运行后终端会隐藏输入并等待你粘贴一次性码。</small>
|
||
</li>
|
||
<li>
|
||
<strong>终端出现提示后,再复制并粘贴一次性码</strong>
|
||
<label className="pairing-copy-field">
|
||
<span>一次性配对码</span>
|
||
<input readOnly value={pairing.bootstrapToken} onFocus={(event) => event.currentTarget.select()} autoComplete="off" spellCheck={false} />
|
||
</label>
|
||
<button className="button button-secondary" type="button" onClick={() => copyText(pairing.bootstrapToken, "token")}>{copied === "token" ? "配对码已复制" : "复制一次性配对码"}</button>
|
||
<small>有效期至 {new Date(pairing.expiresAt).toLocaleString("zh-CN")};终端输入不会回显。</small>
|
||
</li>
|
||
<li>
|
||
<strong>注册成功后启动 daemon</strong>
|
||
<label className="pairing-copy-field compact">
|
||
<span>启动命令</span>
|
||
<input readOnly value={startCommand} onFocus={(event) => event.currentTarget.select()} />
|
||
</label>
|
||
<button className="button button-secondary" type="button" onClick={() => copyText(startCommand, "start")}>{copied === "start" ? "启动命令已复制" : "复制启动命令"}</button>
|
||
<small>保持 daemon 运行;控制面认领完成后,它会在同一个稳定服务地址等待共享 Relay 就绪,不需要轮询或切换后端地址。</small>
|
||
</li>
|
||
</ol>
|
||
|
||
{copyError && <p className="form-error" role="alert">{copyError}</p>}
|
||
{copied && !copyError && <p className="form-success" role="status">复制成功。配对码使用后请用普通文本覆盖剪贴板。</p>}
|
||
<div className="prototype-callout"><strong>使用后立即清除</strong><span>注册命令会在结束时清除进程环境中的配对码;认领成功后服务端摘要也会立即失效。如果响应中途丢失,请在主机列表撤销记录后重新配对。不要截图或写入日志。</span></div>
|
||
<div className="prototype-callout"><strong>恢复原记录需要最新版 daemon</strong><span>保留 identity.json、删除旧 config.json 后重新注册。恢复请求会用原 Ed25519 私钥签名;旧版 daemon 或只有公开指纹的请求会被拒绝。</span></div>
|
||
<div className="prototype-callout"><strong>当前接入边界</strong><span>控制平面会签发独立、可撤销的设备令牌;席位只决定主机能否加入租户,不会自动授予手机读取这台主机的权限。</span></div>
|
||
<div className="pairing-result-actions">
|
||
<a className="button button-secondary" href="/dashboard/hosts">返回主机列表</a>
|
||
<PairingCancelButton
|
||
pairingId={pairing.id}
|
||
onCancelled={() => {
|
||
setPairing(null);
|
||
setTerminal("cancelled");
|
||
setCopied(null);
|
||
setCopyError("");
|
||
setProgressError("");
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<form className="cloud-form pairing-form" onSubmit={submit}>
|
||
{!releaseAvailable && (
|
||
<div className="prototype-callout daemon-preflight" role="group" aria-labelledby="daemon-preflight-title">
|
||
<strong id="daemon-preflight-title">公开 daemon 下载尚未就绪</strong>
|
||
<span>当前不会把你送去下载不确定的“最新版”。只有已经从管理员处取得兼容构建,并核对版本与 SHA-256 的闭测参与者,才应生成十分钟配对码。</span>
|
||
<a className="text-link" href="/download">查看最低 v{minimumDaemonVersion} 要求与下载状态 →</a>
|
||
<label className="switch-row">
|
||
<input type="checkbox" checked={closedBetaBuildConfirmed} onChange={(event) => setClosedBetaBuildConfirmed(event.target.checked)} />
|
||
<span><strong>我已有经过核验的兼容闭测构建</strong><small>确认构建版本不低于 v{minimumDaemonVersion},来源和 SHA-256 已由管理员单独提供并核对。</small></span>
|
||
</label>
|
||
</div>
|
||
)}
|
||
<label><span>主机名称</span><input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:家里工作站" minLength={2} maxLength={48} required /><small>这是控制台显示名,不会成为任意路径输入。</small></label>
|
||
<fieldset><legend>操作系统</legend><div className="choice-grid"><label className={os === "windows" ? "selected" : ""}><input type="radio" name="os" value="windows" checked={os === "windows"} onChange={() => setOs("windows")} /><strong>Windows</strong><small>amd64 · 正式支持</small></label><label className={os === "linux" ? "selected" : ""}><input type="radio" name="os" value="linux" checked={os === "linux"} onChange={() => setOs("linux")} /><strong>Linux</strong><small>amd64 / arm64 · 正式支持</small></label></div></fieldset>
|
||
{error && <p className="form-error" role="alert">{error}</p>}
|
||
<button className="button button-primary" type="submit" disabled={loading || (!releaseAvailable && !closedBetaBuildConfirmed)}>{loading ? "正在创建…" : "生成 10 分钟配对码"}</button>
|
||
</form>
|
||
);
|
||
}
|