feat: establish NekoNest Cloud control and relay

This commit is contained in:
2026-08-12 23:25:43 +08:00
commit f27606b709
222 changed files with 71456 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
export function OpenPwaButton() {
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function openPwa() {
setPending(true);
setError("");
try {
const response = await fetch("/api/pwa/handoff", { method: "POST" });
const payload = await response.json() as {
pwa_url?: string;
message?: string;
};
if (!response.ok || !payload.pwa_url) {
throw new Error(payload.message || "暂时无法打开手机端");
}
window.location.assign(payload.pwa_url);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "暂时无法打开手机端");
setPending(false);
}
}
return (
<span>
<button className="button button-primary" type="button" disabled={pending} onClick={openPwa}>
{pending ? "正在创建安全入口…" : "打开 NekoNest PWA"}
</button>
{error && <small className="form-error" role="alert">{error}</small>}
</span>
);
}
@@ -0,0 +1,97 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import type { BetaAccessRequestRecord } from "@/db/repository";
type SubmitState = { loading: boolean; message: string; error: boolean };
export function AccessRequestForm({
pendingRequest,
}: {
pendingRequest: BetaAccessRequestRecord | null;
}) {
const router = useRouter();
const pendingKey = useRef<string | null>(null);
const [preferredOs, setPreferredOs] = useState("windows");
const [requestedSlots, setRequestedSlots] = useState(1);
const [useCase, setUseCase] = useState("");
const [state, setState] = useState<SubmitState>({ loading: false, message: "", error: false });
async function post(payload: Record<string, unknown>) {
pendingKey.current ??= crypto.randomUUID();
const response = await fetch("/api/beta-access", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...payload, idempotencyKey: pendingKey.current }),
});
const body = (await response.json()) as { message?: string };
if (!response.ok) throw new Error(body.message || "操作失败");
pendingKey.current = null;
}
async function submit(event: React.FormEvent) {
event.preventDefault();
setState({ loading: true, message: "", error: false });
try {
await post({ action: "request", preferredOs, requestedSlots, useCase });
setState({ loading: false, message: "申请已提交,处理结果会显示在本页。", error: false });
router.refresh();
} catch (error) {
setState({ loading: false, message: error instanceof Error ? error.message : "提交失败", error: true });
}
}
async function cancel() {
if (!pendingRequest) return;
setState({ loading: true, message: "", error: false });
try {
await post({ action: "cancel", requestId: pendingRequest.id });
setState({ loading: false, message: "申请已撤回。", error: false });
router.refresh();
} catch (error) {
setState({ loading: false, message: error instanceof Error ? error.message : "撤回失败", error: true });
}
}
if (pendingRequest) {
return (
<div className="cloud-form feedback-form">
<p></p>
<button className="button button-secondary" type="button" onClick={cancel} disabled={state.loading}>
{state.loading ? "正在撤回…" : "撤回这条申请"}
</button>
{state.message && <p className={state.error ? "form-error" : "form-success"} role="status">{state.message}</p>}
</div>
);
}
return (
<form className="cloud-form feedback-form" onSubmit={submit}>
<div className="admin-form-grid">
<label>
<span>使</span>
<select value={preferredOs} onChange={(event) => setPreferredOs(event.target.value)}>
<option value="windows">Windows</option>
<option value="linux">Linux</option>
<option value="both">Windows Linux</option>
</select>
</label>
<label>
<span></span>
<input type="number" min={1} max={3} value={requestedSlots} onChange={(event) => setRequestedSlots(Number(event.target.value))} required />
<small> 13 </small>
</label>
</div>
<label>
<span>使 NekoNest</span>
<textarea minLength={20} maxLength={1000} value={useCase} onChange={(event) => setUseCase(event.target.value)} placeholder="例如:我主要在 Windows 主机上使用 Codex,希望从手机查看并继续已有任务。请勿粘贴令牌、密码、项目代码或私密会话内容。" required />
<small>{useCase.length}/1000 · 使</small>
</label>
<button className="button button-primary" type="submit" disabled={state.loading}>
{state.loading ? "正在提交…" : "申请免费闭测资格"}
</button>
{state.message && <p className={state.error ? "form-error" : "form-success"} role="status">{state.message}</p>}
</form>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { requireCloudViewer } from "../../cloud-auth";
import { DashboardShell, PageHeading, StatusPill, formatDate } from "../../components/Shells";
import { getDashboardSnapshot, getOrCreateAccount } from "@/db/repository";
import { getBillingEntitlementPresentation } from "@/db/domain";
import { deriveInvitationDisplayState } from "@/db/invitations";
import { AccessRequestForm } from "./AccessRequestForm";
export const dynamic = "force-dynamic";
export default async function BillingPage() {
const viewer = await requireCloudViewer("/dashboard/billing");
const account = await getOrCreateAccount(viewer);
const snapshot = await getDashboardSnapshot(account);
const billingCopy = getBillingEntitlementPresentation(
snapshot.entitlement.mode,
snapshot.entitlement.publicBetaState,
);
const now = new Date();
const nowIso = now.toISOString();
const invitations = snapshot.grants.filter((grant) => grant.source === "admin_exemption");
const pendingAccessRequest = snapshot.accessRequests.find((request) => request.status === "requested") ?? null;
const endingSoon = invitations.find((invitation) => {
if (deriveInvitationDisplayState(invitation, nowIso) !== "active" || !invitation.ends_at) return false;
const remaining = new Date(invitation.ends_at).getTime() - now.getTime();
return remaining > 0 && remaining <= 7 * 24 * 60 * 60_000;
});
const available = snapshot.entitlement.unlimited
? "当前不按槽位限额"
: `${snapshot.entitlement.availableSlots ?? 0}`;
return (
<DashboardShell viewer={viewer} active="/dashboard/billing">
<div className="cloud-page">
<PageHeading
eyebrow="PUBLIC BETA / 公测权益"
title={snapshot.entitlement.mode === "none" ? "这里说明当前免费资格状态。" : "当前免费,不需要处理账单。"}
description="这里仅显示公测资格、主机占用和透明容量规则。报价、订单与支付功能暂不开放。"
/>
<section className="billing-status-panel">
<div>
<StatusPill tone={billingCopy.tone}>{billingCopy.status}</StatusPill>
<h2>{billingCopy.title}</h2>
<p>{billingCopy.description}</p>
</div>
<dl>
<div><dt></dt><dd>{snapshot.entitlement.activeSlots}</dd></div>
<div><dt></dt><dd>{snapshot.entitlement.reservedSlots}</dd></div>
<div><dt></dt><dd>{available}</dd></div>
<div><dt></dt><dd>{formatDate(snapshot.entitlement.effectiveUntil)}</dd></div>
</dl>
</section>
{endingSoon && (
<section className="panel full-panel attention-panel" role="status">
<div className="panel-heading"><div><span>CLOSED BETA NOTICE</span><h2></h2></div><StatusPill tone="warn"></StatusPill></div>
<p> {formatDate(endingSoon.ends_at, true)} </p>
</section>
)}
<div className="dashboard-columns billing-columns">
<section className="panel">
<div className="panel-heading"><div><span></span><h2></h2></div></div>
<div className="billing-rules">
<span> </span>
<span> </span>
<span> </span>
<span> </span>
</div>
</section>
<section className="panel">
<div className="panel-heading"><div><span></span><h2></h2></div></div>
<p>使</p>
<div className="empty-inline"></div>
</section>
</div>
<section className="panel full-panel">
<div className="panel-heading">
<div><span>CLOSED BETA / </span><h2></h2></div>
<StatusPill tone={pendingAccessRequest ? "warn" : snapshot.entitlement.mode === "none" ? "neutral" : "good"}>{pendingAccessRequest ? "等待处理" : snapshot.entitlement.mode === "none" ? "可申请" : "已有资格"}</StatusPill>
</div>
{snapshot.entitlement.mode !== "none" ? (
<div className="empty-inline"></div>
) : (
<AccessRequestForm pendingRequest={pendingAccessRequest} />
)}
{snapshot.accessRequests.length > 0 && (
<div className="admin-gate-table invitation-history">
{snapshot.accessRequests.map((request) => (
<article key={request.id}>
<StatusPill tone={request.status === "approved" ? "good" : request.status === "requested" ? "warn" : "neutral"}>{request.status === "approved" ? "已批准" : request.status === "requested" ? "审核中" : request.status === "declined" ? "暂未批准" : "已撤回"}</StatusPill>
<div><strong>{request.requested_slots} · {request.preferred_os === "both" ? "Windows 和 Linux" : request.preferred_os === "windows" ? "Windows" : "Linux"}</strong><small>{request.id}</small></div>
<span>{formatDate(request.requested_at, true)}</span>
<span>{request.admin_response || "尚无处理说明"}</span>
</article>
))}
</div>
)}
</section>
<section className="panel full-panel">
<div className="panel-heading"><div><span>CLOSED BETA / </span><h2></h2></div><StatusPill tone={invitations.some((invitation) => deriveInvitationDisplayState(invitation, nowIso) === "active") ? "good" : "neutral"}>{invitations.filter((invitation) => deriveInvitationDisplayState(invitation, nowIso) === "active").length} </StatusPill></div>
<p></p>
<div className="admin-gate-table invitation-history">
{invitations.length ? invitations.map((invitation) => {
const state = deriveInvitationDisplayState(invitation, nowIso);
return <article key={invitation.id}><StatusPill tone={state === "active" ? "good" : "neutral"}>{state === "active" ? "有效" : state === "expired" ? "已到期" : "已撤销"}</StatusPill><div><strong>{invitation.capacity_slots === null ? "当前不按主机槽位限额" : `${invitation.capacity_slots} 个主机槽位`}</strong><small>{invitation.id}</small></div><span>{formatDate(invitation.starts_at, true)}</span><span>{formatDate(invitation.ends_at, true)}</span></article>;
}) : <div className="empty-inline"></div>}
</div>
</section>
<section className="panel full-panel">
<div className="panel-heading"><div><span></span><h2></h2></div></div>
<div className="billing-rules">
<span>1. </span>
<span>2. </span>
<span>3. </span>
<span>4. 退</span>
</div>
</section>
</div>
</DashboardShell>
);
}
+39
View File
@@ -0,0 +1,39 @@
import type { TenantConnectionSummary } from "@/db/repository";
export type ConnectionCopy = {
label: string;
detail: string;
nextStep: string;
tone: "good" | "warn" | "danger" | "neutral" | "info";
};
const connectionCopy: Record<TenantConnectionSummary["state"], ConnectionCopy> = {
provisioning: {
label: "正在分配中继",
detail: "主机已认领;租户正在分配 home region 与共享 Relay 节点。",
nextStep: "保持 daemon 的服务地址与设备令牌不变,它会在同一地址自动重试。",
tone: "info",
},
ready: {
label: "中继已就绪",
detail: "当前 placement generation 已分配到健康的共享 Relay 节点。",
nextStep: "daemon 继续连接注册时使用的稳定服务地址;节点位置不会暴露给客户端。",
tone: "good",
},
suspended: {
label: "租户已暂停",
detail: "运行时或公测资格当前处于暂停状态,设备记录仍被保留。",
nextStep: "在恢复前不要删除本地主机配置。",
tone: "warn",
},
unavailable: {
label: "状态待核实",
detail: "控制面缺少完整 placement 或授权状态,因此不会允许建立连接。",
nextStep: "稍后重试;持续出现时联系公测维护者。",
tone: "warn",
},
};
export function getConnectionCopy(state: TenantConnectionSummary["state"]): ConnectionCopy {
return connectionCopy[state];
}
+15
View File
@@ -0,0 +1,15 @@
"use client";
import { RouteErrorState } from "../components/RouteStates";
export default function DashboardError({ reset }: { reset: () => void }) {
return (
<RouteErrorState
area="控制台"
title="控制台暂时没有加载出来"
description="账户或主机状态这次未能安全读取,因此页面没有猜测或沿用旧结果。"
reset={reset}
dashboard
/>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import type { FeedbackCategory } from "@/db/repository";
type SubmitState = { loading: boolean; message: string; error: boolean };
export function FeedbackForm() {
const router = useRouter();
const pendingKey = useRef<string | null>(null);
const [category, setCategory] = useState<FeedbackCategory>("connection_issue");
const [message, setMessage] = useState("");
const [state, setState] = useState<SubmitState>({
loading: false,
message: "",
error: false,
});
async function submit(event: React.FormEvent) {
event.preventDefault();
setState({ loading: true, message: "", error: false });
pendingKey.current ??= crypto.randomUUID();
try {
const response = await fetch("/api/feedback", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
category,
message,
idempotencyKey: pendingKey.current,
}),
});
const body = (await response.json()) as { message?: string };
if (!response.ok) throw new Error(body.message || "提交失败");
pendingKey.current = null;
setMessage("");
setState({
loading: false,
message: "已收到。处理结果会显示在本页,不需要重复提交。",
error: false,
});
router.refresh();
} catch (error) {
setState({
loading: false,
message: error instanceof Error ? error.message : "提交失败",
error: true,
});
}
}
return (
<form className="cloud-form feedback-form" onSubmit={submit}>
<label>
<span></span>
<select
value={category}
onChange={(event) => setCategory(event.target.value as FeedbackCategory)}
>
<option value="connection_issue"></option>
<option value="bug"></option>
<option value="suggestion">使</option>
<option value="other"></option>
</select>
</label>
<label>
<span></span>
<textarea
minLength={10}
maxLength={2000}
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="请写清出现了什么、你原本想完成什么;不要粘贴令牌、密码或私密会话内容。"
required
/>
<small>{message.length}/2000 · </small>
</label>
<button className="button button-primary" type="submit" disabled={state.loading}>
{state.loading ? "正在提交…" : "提交反馈"}
</button>
{state.message && (
<p className={state.error ? "form-error" : "form-success"} role="status">
{state.message}
</p>
)}
</form>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { requireCloudViewer } from "../../cloud-auth";
import {
DashboardShell,
PageHeading,
StatusPill,
formatDate,
} from "../../components/Shells";
import {
getOrCreateAccount,
listAccountFeedback,
type FeedbackCategory,
} from "@/db/repository";
import { FeedbackForm } from "./FeedbackForm";
export const dynamic = "force-dynamic";
const categoryLabels: Record<FeedbackCategory, string> = {
connection_issue: "连接或接入",
bug: "功能异常",
suggestion: "使用建议",
other: "其他",
};
export default async function FeedbackPage() {
const viewer = await requireCloudViewer("/dashboard/feedback");
const account = await getOrCreateAccount(viewer);
const feedback = await listAccountFeedback(account.id);
return (
<DashboardShell viewer={viewer} active="/dashboard/feedback">
<div className="cloud-page narrow-cloud-page">
<PageHeading
eyebrow="BETA FEEDBACK / 公测反馈"
title="遇到问题,直接告诉我们。"
description="这是免费公测期的站内反馈通道。后台回复会保存在这里;当前不承诺即时客服,但接入故障会优先处理。"
/>
<div className="feedback-layout">
<section className="panel form-panel">
<div className="panel-heading">
<div><span></span><h2></h2></div>
</div>
<FeedbackForm />
</section>
<aside className="form-aside feedback-aside">
<span className="eyebrow"></span>
<h2></h2>
<ol>
<li><strong></strong><p></p></li>
<li><strong></strong><p> daemon </p></li>
<li><strong></strong><p></p></li>
</ol>
</aside>
</div>
<section className="panel full-panel">
<div className="panel-heading">
<div><span></span><h2></h2></div>
<StatusPill tone="info">{feedback.length} </StatusPill>
</div>
{feedback.length ? (
<div className="feedback-list">
{feedback.map((item) => (
<article key={item.id}>
<div className="feedback-meta">
<StatusPill tone={item.status === "resolved" ? "good" : "warn"}>
{item.status === "resolved" ? "已回复" : "待处理"}
</StatusPill>
<span>{categoryLabels[item.category]}</span>
<time>{formatDate(item.created_at, true)}</time>
</div>
<p>{item.message}</p>
{item.admin_response && (
<div className="feedback-response">
<strong></strong>
<p>{item.admin_response}</p>
</div>
)}
</article>
))}
</div>
) : (
<div className="empty-inline"></div>
)}
</section>
</div>
</DashboardShell>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function HostRevokeButton({ hostId }: { hostId: string }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function revoke() {
if (!window.confirm("撤销后 daemon 令牌立即失效并释放槽位。保留主机 identity.json 时可通过新的配对码安全恢复。确定继续?")) return;
setBusy(true);
setError("");
try {
const response = await fetch("/api/hosts/revoke", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ hostId, reason: "用户从主机列表撤销设备凭据" }),
});
const body = (await response.json()) as { message?: string };
if (!response.ok) throw new Error(body.message || "撤销失败");
router.refresh();
} catch (revokeError) {
setError(revokeError instanceof Error ? revokeError.message : "撤销失败");
} finally {
setBusy(false);
}
}
return (
<div>
<button className="button button-secondary" type="button" onClick={revoke} disabled={busy}>
{busy ? "正在撤销…" : "撤销主机"}
</button>
{error && <small className="form-error" role="alert">{error}</small>}
</div>
);
}
@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function PairingCancelButton({
pairingId,
onCancelled,
}: {
pairingId: string;
onCancelled?: () => void;
}) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function cancel() {
if (
!window.confirm(
"取消后这枚配对凭证立即失效并释放占位。若 daemon 正在认领,最终状态以主机列表为准。确定取消?",
)
) {
return;
}
setBusy(true);
setError("");
try {
const response = await fetch("/api/hosts/pairing/cancel", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ pairingId }),
});
const body = (await response.json()) as { message?: string };
if (!response.ok) {
throw new Error(body.message || "取消配对请求失败");
}
onCancelled?.();
router.refresh();
} catch (cancelError) {
setError(
cancelError instanceof Error ? cancelError.message : "取消配对请求失败",
);
} finally {
setBusy(false);
}
}
return (
<div className="pairing-cancel-action">
<button
className="button button-secondary"
type="button"
onClick={cancel}
disabled={busy}
>
{busy ? "正在取消…" : "取消配对"}
</button>
{error && (
<small className="form-error" role="alert">
{error}
</small>
)}
</div>
);
}
+363
View File
@@ -0,0 +1,363 @@
"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> revisiondaemon </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>
);
}
+72
View File
@@ -0,0 +1,72 @@
export type SupportedHostOS = "windows" | "linux";
export function assertSafeConnectOrigin(value: string): string {
const origin = value.trim().replace(/\/$/, "");
const parsed = new URL(origin);
const isLoopback = ["localhost", "127.0.0.1", "[::1]"].includes(parsed.hostname);
if (parsed.origin !== origin || (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback))) {
throw new Error("Cloud Connect 服务必须使用 HTTPS origin(本机开发地址除外)");
}
return origin;
}
export function resolveConnectOrigin(
configured: string | undefined,
development = process.env.NODE_ENV !== "production",
): string {
const value = configured?.trim() ?? "";
if (value) return assertSafeConnectOrigin(value);
if (development) return "http://127.0.0.1:3000";
throw new Error("NEKONEST_CLOUD_CONNECT_ORIGIN is required outside development");
}
export function quotePowerShell(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
export function quoteBash(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
export function buildDaemonRegistrationCommand(input: {
os: SupportedHostOS;
connectOrigin: string;
hostName: string;
}): string {
const connectOrigin = assertSafeConnectOrigin(input.connectOrigin);
const hostName = input.hostName.trim();
if (!hostName) throw new Error("主机名称不能为空");
if (input.os === "windows") {
return [
`$env:NEKONEST_SERVER = ${quotePowerShell(connectOrigin)}`,
`$env:NEKONEST_TRANSPORT_MODE = 'sealed'`,
`$secureToken = Read-Host '粘贴一次性配对码' -AsSecureString`,
`$tokenPtr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)`,
"try {",
` $env:NEKONEST_BOOTSTRAP_TOKEN = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($tokenPtr)`,
` if ([string]::IsNullOrWhiteSpace($env:NEKONEST_BOOTSTRAP_TOKEN)) { throw '未读取到配对码' }`,
` & '.\\nekonest-daemon.exe' -register -name ${quotePowerShell(hostName)}`,
"} finally {",
" Remove-Item Env:NEKONEST_BOOTSTRAP_TOKEN -ErrorAction SilentlyContinue",
" [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($tokenPtr)",
"}",
].join("\n");
}
return [
"(",
" read -rsp '粘贴一次性配对码: ' NEKONEST_BOOTSTRAP_TOKEN",
" printf '\\n'",
` if [ -z "$NEKONEST_BOOTSTRAP_TOKEN" ]; then printf '未读取到配对码\\n' >&2; exit 1; fi`,
` export NEKONEST_SERVER=${quoteBash(connectOrigin)}`,
" export NEKONEST_TRANSPORT_MODE='sealed'",
" export NEKONEST_BOOTSTRAP_TOKEN",
` ./nekonest-daemon -register -name ${quoteBash(hostName)}`,
")",
].join("\n");
}
export function daemonStartCommand(os: SupportedHostOS): string {
return os === "windows" ? ".\\nekonest-daemon.exe" : "./nekonest-daemon";
}
+39
View File
@@ -0,0 +1,39 @@
import Link from "next/link";
import { requireCloudViewer } from "../../../cloud-auth";
import { DashboardShell, PageHeading } from "../../../components/Shells";
import { getDashboardSnapshot, getOrCreateAccount } from "@/db/repository";
import { getDaemonReleaseState } from "../../../daemon-release";
import { deriveBetaOnboarding } from "../../onboarding";
import { PairingForm } from "./PairingForm";
import { resolveConnectOrigin } from "./onboarding";
import { env } from "cloudflare:workers";
export const dynamic = "force-dynamic";
export default async function NewHostPage() {
const viewer = await requireCloudViewer("/dashboard/hosts/new");
const account = await getOrCreateAccount(viewer);
const [snapshot, daemonRelease] = await Promise.all([
getDashboardSnapshot(account),
getDaemonReleaseState(),
]);
const onboarding = deriveBetaOnboarding({
entitlement: snapshot.entitlement,
hasPendingRequest: snapshot.accessRequests.some((request) => request.status === "requested"),
});
const connectOrigin = resolveConnectOrigin(
env.NEKONEST_CLOUD_CONNECT_ORIGIN,
process.env.NODE_ENV !== "production",
);
return (
<DashboardShell viewer={viewer} active="/dashboard/hosts">
<div className="cloud-page narrow-cloud-page">
<PageHeading eyebrow="ADD OR RECOVER HOST / 添加或恢复主机" title="先创建一次性配对请求。" description="新主机和已撤销主机的安全恢复共用这一步;家里不需要打开入站端口。" actions={<Link className="button button-secondary" href="/download"> daemon</Link>} />
<div className="form-layout">
<section className="panel form-panel"><PairingForm accessState={onboarding.pairingAccessState} releaseAvailable={daemonRelease.available} minimumDaemonVersion={daemonRelease.minimumVersion} connectOrigin={connectOrigin} /></section>
<aside className="form-aside"><span className="eyebrow"></span><ol><li><strong></strong><p> SHA-256 </p></li><li><strong>daemon </strong><p>daemon </p></li><li><strong></strong><p> daemon </p></li><li><strong></strong><p></p></li></ol></aside>
</div>
</div>
</DashboardShell>
);
}
+95
View File
@@ -0,0 +1,95 @@
import Link from "next/link";
import { requireCloudViewer } from "../../cloud-auth";
import { DashboardShell, PageHeading, StatusPill, formatDate } from "../../components/Shells";
import { getDashboardSnapshot, getOrCreateAccount } from "@/db/repository";
import { HostRevokeButton } from "./HostRevokeButton";
import { PairingCancelButton } from "./PairingCancelButton";
import { getConnectionCopy } from "../connection-copy";
import { deriveControlPlaneContact } from "@/db/device-control-plane";
export const dynamic = "force-dynamic";
function lifecycleLabel(value: string) {
return value === "active" ? "已启用" : value === "deactivated" ? "已撤销" : value;
}
function slotLabel(value: string) {
return value === "active" ? "占用中" : value === "released" ? "已释放" : value;
}
export default async function HostsPage() {
const viewer = await requireCloudViewer("/dashboard/hosts");
const account = await getOrCreateAccount(viewer);
const snapshot = await getDashboardSnapshot(account);
const connection = getConnectionCopy(snapshot.connection.state);
return (
<DashboardShell viewer={viewer} active="/dashboard/hosts">
<div className="cloud-page">
<PageHeading
eyebrow="HOSTS / 主机"
title="主机与槽位"
description="只有启用的持久主机记录占槽位。离线不释放;明确停用或撤销后才释放。"
actions={<Link className="button button-primary" href="/dashboard/hosts/new"></Link>}
/>
<div className="entitlement-bar">
<div><span></span><strong>{snapshot.entitlement.mode === "public_beta" ? "公开公测" : snapshot.entitlement.mode === "grant" ? "闭测邀请" : "无"}</strong></div>
<div><span></span><strong>{snapshot.entitlement.activeSlots}</strong></div>
<div><span></span><strong>{snapshot.entitlement.reservedSlots}</strong></div>
<div><span></span><strong>{snapshot.entitlement.unlimited ? "不按槽位限额" : snapshot.entitlement.availableSlots}</strong></div>
</div>
<section className="panel full-panel">
<div className="panel-heading"><div><span></span><h2></h2></div><StatusPill tone={connection.tone}>{connection.label}</StatusPill></div>
<p>{connection.detail} {connection.nextStep}</p>
<p className="host-contact-note"> Cloud 线 Relay </p>
{snapshot.hosts.length ? (
<div className="host-list">
{snapshot.hosts.map((host) => {
const contact = deriveControlPlaneContact(host.control_plane_last_seen_at);
return (
<article className="host-row detailed-host" key={host.id}>
<span className={`host-icon host-${host.os}`}>{host.os === "windows" ? "W" : "L"}</span>
<div><strong>{host.name}</strong><small>{host.id}</small></div>
<div><span className="row-label"></span><strong>{slotLabel(host.slot_state)}</strong></div>
<div>
<span className="row-label">Daemon / </span>
<strong>{host.daemon_version || "版本待上报"}</strong>
<small>{host.control_plane_last_seen_at ? formatDate(host.control_plane_last_seen_at, true) : "等待首次 Relay 授权"}</small>
</div>
<StatusPill tone={host.lifecycle === "active" ? contact.tone : "neutral"}>{host.lifecycle === "active" ? contact.label : lifecycleLabel(host.lifecycle)}</StatusPill>
{host.lifecycle === "active" && <HostRevokeButton hostId={host.id} />}
{host.lifecycle === "deactivated" && <Link className="button button-secondary" href="/dashboard/hosts/new"></Link>}
</article>
);
})}
</div>
) : (
<div className="empty-state"><span className="empty-symbol"></span><h3></h3><p>线</p><Link className="button button-primary" href="/dashboard/hosts/new"></Link></div>
)}
</section>
<section className="panel full-panel recovery-guide">
<div className="panel-heading"><div><span></span><h2></h2></div></div>
<div className="recovery-options">
<article><StatusPill tone="good">identity.json </StatusPill><h3></h3><p> identity.json config.json使 daemon daemon Cloud ID </p></article>
<article><StatusPill tone="warn"></StatusPill><h3></h3><p> ID</p></article>
</div>
</section>
<section className="panel full-panel">
<div className="panel-heading"><div><span></span><h2></h2></div></div>
{snapshot.pairings.length ? (
<div className="pairing-list">
{snapshot.pairings.map((pairing) => (
<article key={pairing.id}><div><strong>{pairing.requested_name}</strong><small>{pairing.os.toUpperCase()} · {pairing.id}</small></div><StatusPill tone="warn"> daemon</StatusPill><span>{formatDate(pairing.expires_at, true)}</span><PairingCancelButton pairingId={pairing.id} /></article>
))}
</div>
) : (
<div className="empty-inline"></div>
)}
</section>
</div>
</DashboardShell>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { RouteLoadingState } from "../components/RouteStates";
export default function DashboardLoading() {
return (
<RouteLoadingState
area="控制台"
description="正在核对账户、公测权益、主机和服务状态,请稍候。"
/>
);
}
+83
View File
@@ -0,0 +1,83 @@
export type PairingAccessState =
| "available"
| "request_pending"
| "gated"
| "inactive"
| "full";
export type BetaOnboardingState =
| "request_needed"
| "request_pending"
| "ready"
| "full";
export type BetaOnboarding = {
state: BetaOnboardingState;
pairingAccessState: PairingAccessState;
primaryHref: string;
primaryLabel: string;
tone: "good" | "warn" | "neutral" | "info";
title: string;
detail: string;
};
export function deriveBetaOnboarding(input: {
entitlement: {
mode: "public_beta" | "grant" | "none";
publicBetaState: "open" | "gated" | "inactive";
unlimited: boolean;
availableSlots: number | null;
};
hasPendingRequest: boolean;
}): BetaOnboarding {
const { entitlement } = input;
if (entitlement.mode === "none") {
if (input.hasPendingRequest) {
return {
state: "request_pending",
pairingAccessState: "request_pending",
primaryHref: "/dashboard/billing",
primaryLabel: "查看申请进度",
tone: "warn",
title: "免费闭测申请正在审核",
detail: "处理结果会显示在公测权益页;审核期间不需要重复申请。",
};
}
const gated = entitlement.publicBetaState === "gated";
return {
state: "request_needed",
pairingAccessState: gated ? "gated" : "inactive",
primaryHref: "/dashboard/billing",
primaryLabel: "申请免费闭测",
tone: gated ? "warn" : "neutral",
title: gated ? "公开接入尚未开放" : "当前没有免费测试资格",
detail: gated
? "安全门禁仍在核对,可以先申请小范围免费闭测。"
: "可以提交免费闭测申请;不会绑定支付方式或自动转为付费。",
};
}
if (!entitlement.unlimited && (entitlement.availableSlots ?? 0) < 1) {
return {
state: "full",
pairingAccessState: "full",
primaryHref: "/dashboard/hosts",
primaryLabel: "管理已接入主机",
tone: "neutral",
title: "当前免费主机名额已用完",
detail: "可以取消等待中的配对、撤销不再使用的主机,或等待管理员调整闭测名额。",
};
}
return {
state: "ready",
pairingAccessState: "available",
primaryHref: "/dashboard/hosts/new",
primaryLabel: "连接主机",
tone: "good",
title: entitlement.mode === "grant" ? "闭测资格已生效" : "免费公测资格已生效",
detail: entitlement.unlimited
? "当前策略不按主机槽位限额,可以开始连接主机。"
: `还可以连接 ${entitlement.availableSlots ?? 0} 台主机。`,
};
}
+138
View File
@@ -0,0 +1,138 @@
import Link from "next/link";
import { requireCloudViewer } from "../cloud-auth";
import {
DashboardShell,
PageHeading,
StatusPill,
formatDate,
} from "../components/Shells";
import { getDashboardSnapshot, getOrCreateAccount } from "@/db/repository";
import { getConnectionCopy } from "./connection-copy";
import { OpenPwaButton } from "./OpenPwaButton";
import { deriveControlPlaneContact } from "@/db/device-control-plane";
import { deriveBetaOnboarding } from "./onboarding";
export const dynamic = "force-dynamic";
export default async function DashboardPage() {
const viewer = await requireCloudViewer("/dashboard");
const account = await getOrCreateAccount(viewer);
const snapshot = await getDashboardSnapshot(account);
const activeHosts = snapshot.hosts.filter((host) => host.lifecycle === "active");
const entitlement = snapshot.entitlement;
const connection = getConnectionCopy(snapshot.connection.state);
const onboarding = deriveBetaOnboarding({
entitlement,
hasPendingRequest: snapshot.accessRequests.some((request) => request.status === "requested"),
});
const entitlementLabel = entitlement.mode === "public_beta"
? "公测免费"
: entitlement.mode === "grant"
? "闭测邀请"
: entitlement.publicBetaState === "gated"
? "等待安全门禁"
: "无有效权益";
const entitlementDetail = entitlement.mode === "none" && entitlement.publicBetaState === "gated"
? `公开接入仍有 ${entitlement.blockedP0} 项 P0 未通过;管理员邀请账户可继续闭测`
: entitlement.unlimited
? "费用全免;当前策略未按槽位限额"
: `可用 ${entitlement.availableSlots ?? 0} 个槽位`;
return (
<DashboardShell
viewer={viewer}
active="/dashboard"
serviceStatus={snapshot.serviceStatus}
>
<div className="cloud-page">
<PageHeading
eyebrow="OVERVIEW / 总览"
title={`晚上好,${viewer.displayName}`}
description="这里显示控制平面真实保存的公测接入状态;原生会话仍由你的主机和 NekoNest PWA 提供。"
actions={snapshot.connection.state === "ready"
? <OpenPwaButton />
: <Link className="button button-primary" href={onboarding.primaryHref}>{onboarding.primaryLabel}</Link>}
/>
<section className="metric-grid" aria-label="账户概览">
<article className="metric-card accent-card">
<span></span>
<strong>{entitlementLabel}</strong>
<small>{entitlementDetail}</small>
</article>
<article className="metric-card">
<span></span>
<strong>{entitlement.activeSlots}</strong>
<small>{entitlement.reservedSlots ? `${entitlement.reservedSlots} 个配对请求占位中` : "没有等待配对的占位"}</small>
</article>
<article className="metric-card">
<span></span>
<strong>{connection.label}</strong>
<small>{snapshot.connection.homeRegion
? `${snapshot.connection.homeRegion} · generation ${snapshot.connection.placementGeneration ?? "-"}`
: "等待 home region 分配"}</small>
</article>
<article className="metric-card">
<span></span>
<strong></strong>
<small></small>
</article>
</section>
<div className="dashboard-columns">
<section className="panel">
<div className="panel-heading">
<div><span></span><h2></h2></div>
<Link href="/dashboard/hosts"> </Link>
</div>
{activeHosts.length ? (
<div className="host-list">
{activeHosts.slice(0, 4).map((host) => {
const contact = deriveControlPlaneContact(host.control_plane_last_seen_at);
return (
<article className="host-row" key={host.id}>
<span className={`host-icon host-${host.os}`}>{host.os === "windows" ? "W" : "L"}</span>
<div><strong>{host.name}</strong><small>{host.daemon_version || "daemon 版本待上报"}</small></div>
<div className="host-state">
<StatusPill tone={contact.tone}>{contact.label}</StatusPill>
<small>{host.control_plane_last_seen_at ? formatDate(host.control_plane_last_seen_at, true) : "等待首次 Relay 授权"}</small>
</div>
</article>
);
})}
</div>
) : (
<div className="empty-state compact-empty">
<span className="empty-symbol"></span>
<h3></h3>
<p>daemon Relay </p>
<Link className="button button-secondary" href="/dashboard/hosts/new"></Link>
</div>
)}
</section>
<section className="panel attention-panel">
<div className="panel-heading"><div><span></span><h2></h2></div></div>
<div className="attention-list">
{onboarding.state !== "ready" && <article><StatusPill tone={onboarding.tone}>{onboarding.state === "request_pending" ? "审核中" : onboarding.state === "request_needed" ? "先申请" : "容量已满"}</StatusPill><div><strong>{onboarding.title}</strong><p>{onboarding.detail} <Link className="text-link" href={onboarding.primaryHref}>{onboarding.primaryLabel} </Link></p></div></article>}
<article><StatusPill tone={connection.tone}>{connection.label}</StatusPill><div><strong>{connection.detail}</strong><p>{connection.nextStep}</p></div></article>
{entitlement.publicBetaState === "gated" && <article><StatusPill tone="warn"></StatusPill><div><strong>P0 </strong><p></p></div></article>}
<article><StatusPill tone="info"></StatusPill><div><strong></strong><p></p></div></article>
<article><StatusPill tone="info"></StatusPill><div><strong></strong><p></p></div></article>
</div>
</section>
</div>
<section className="path-panel">
<div><span className="eyebrow">CONNECTION PATH</span><h2></h2></div>
<ol>
<li className={entitlement.mode !== "none" ? "done" : "current"}><span>01</span><div><strong></strong><p>{onboarding.title}{onboarding.detail}</p></div></li>
<li className={snapshot.pairings.length || activeHosts.length ? "done" : onboarding.state === "ready" ? "current" : undefined}><span>02</span><div><strong></strong><p></p></div></li>
<li className={activeHosts.length ? "done" : snapshot.pairings.length ? "current" : undefined}><span>03</span><div><strong>daemon </strong><p> authorization revision</p></div></li>
<li className={snapshot.connection.state === "ready" ? "done" : activeHosts.length ? "current" : undefined}><span>04</span><div><strong> sealed Relay </strong><p>{snapshot.connection.state === "ready" ? "daemon 保持稳定服务地址即可连接。" : "等待租户 placement 指向健康节点;不会向客户端释放后端节点 URL。"}</p></div></li>
</ol>
</section>
</div>
</DashboardShell>
);
}
@@ -0,0 +1,148 @@
"use client";
import { useState } from "react";
import type { AccountDeletionRequestRecord } from "@/db/repository";
type SubmitState = { loading: boolean; message: string; error: boolean };
const idle: SubmitState = { loading: false, message: "", error: false };
async function postDeletion(payload: Record<string, unknown>) {
const response = await fetch("/api/account/deletion", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
...payload,
idempotencyKey: crypto.randomUUID(),
}),
});
const body = (await response.json()) as { message?: string };
if (!response.ok) throw new Error(body.message || "操作失败");
}
function formatRequestDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
export function AccountDeletionAction({
requests,
}: {
requests: AccountDeletionRequestRecord[];
}) {
const active = requests.find((request) => request.status === "requested");
const processing = requests.find((request) =>
request.status === "processing" || request.status === "relay_purged"
);
const [confirmed, setConfirmed] = useState(false);
const [reason, setReason] = useState("");
const [state, setState] = useState(idle);
async function submitRequest(event: React.FormEvent) {
event.preventDefault();
setState({ loading: true, message: "", error: false });
try {
await postDeletion({ action: "request", confirmed, reason });
setState({ loading: false, message: "注销申请已记录。", error: false });
window.setTimeout(() => window.location.reload(), 700);
} catch (error) {
setState({
loading: false,
message: error instanceof Error ? error.message : "操作失败",
error: true,
});
}
}
async function cancelRequest() {
if (!active) return;
setState({ loading: true, message: "", error: false });
try {
await postDeletion({ action: "cancel", requestId: active.id });
setState({ loading: false, message: "注销申请已撤回。", error: false });
window.setTimeout(() => window.location.reload(), 700);
} catch (error) {
setState({
loading: false,
message: error instanceof Error ? error.message : "操作失败",
error: true,
});
}
}
if (active) {
return (
<div className="deletion-request-state">
<span></span>
<small>{formatRequestDate(active.requested_at)}</small>
{active.reason && <p>{active.reason}</p>}
<button
className="button button-secondary"
type="button"
onClick={cancelRequest}
disabled={state.loading}
>
{state.loading ? "正在撤回…" : "撤回注销申请"}
</button>
{state.message && (
<p className={state.error ? "form-error" : "form-success"} role="status">
{state.message}
</p>
)}
</div>
);
}
if (processing) {
return (
<div className="deletion-request-state">
<span>{processing.status === "relay_purged" ? "Relay 数据已逻辑删除" : "正在永久删除 Relay 数据"}</span>
<small>{formatRequestDate(processing.requested_at)}</small>
<p>
{processing.status === "relay_purged"
? "实时 Relay 数据、附件与备份已删除;账户身份和依法保留记录仍按最终退出政策处理。"
: "访问已经暂停,删除期间不能撤回申请。"}
</p>
</div>
);
}
return (
<form className="deletion-request-form" onSubmit={submitRequest}>
<label>
<span></span>
<textarea
maxLength={500}
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="例如:暂时不再参加公测"
/>
</label>
<label className="deletion-confirmation">
<input
type="checkbox"
checked={confirmed}
onChange={(event) => setConfirmed(event.target.checked)}
required
/>
<span></span>
</label>
<button
className="button button-secondary"
type="submit"
disabled={state.loading || !confirmed}
>
{state.loading ? "正在提交…" : "提交注销申请"}
</button>
{state.message && (
<p className={state.error ? "form-error" : "form-success"} role="status">
{state.message}
</p>
)}
</form>
);
}
+34
View File
@@ -0,0 +1,34 @@
import Link from "next/link";
import { requireCloudViewer } from "../../cloud-auth";
import { DashboardShell, PageHeading, StatusPill } from "../../components/Shells";
import {
getDashboardSnapshot,
getOrCreateAccount,
listAccountDeletionRequests,
} from "@/db/repository";
import { AccountDeletionAction } from "./AccountLifecycleActions";
export const dynamic = "force-dynamic";
export default async function SecurityPage() {
const viewer = await requireCloudViewer("/dashboard/security");
const account = await getOrCreateAccount(viewer);
const [snapshot, deletionRequests] = await Promise.all([
getDashboardSnapshot(account),
listAccountDeletionRequests(account.id),
]);
return (
<DashboardShell viewer={viewer} active="/dashboard/security" serviceStatus={snapshot.serviceStatus}>
<div className="cloud-page">
<PageHeading eyebrow="SECURITY / 安全与设备" title="把身份、设备和内容边界分开。" description="用户、手机、主机和管理员使用不同身份;控制平面不把登录成功当作跨租户授权。" />
<div className="security-grid">
<section className="panel security-card"><span className="card-index">01</span><StatusPill tone="good"></StatusPill><h2>{viewer.email}</h2><p>{viewer.isLocalDemo ? "本地开发演示身份;部署后不会存在。" : "由 Sites 的 ChatGPT 登录识别;正式公共身份提供商仍需上线前确认。"}</p></section>
<section className="panel security-card"><span className="card-index">02</span><StatusPill tone={snapshot.connection.state === "ready" ? "good" : "warn"}></StatusPill><h2>{snapshot.tenant?.slug}</h2><p> Relay {snapshot.connection.state} revision {snapshot.connection.authorizationRevision} phone device grant</p></section>
<section className="panel security-card"><span className="card-index">03</span><StatusPill tone="danger"></StatusPill><h2>sealed attachments</h2><p></p></section>
</div>
<section className="panel full-panel"><div className="panel-heading"><div><span></span><h2></h2></div></div><div className="safety-actions"><article><strong></strong><p></p><Link className="button button-secondary" href="/dashboard/hosts"></Link></article><article><strong></strong><p> Cloud </p><a className="button button-secondary" href="/api/account/export" download> JSON </a></article><article className="account-lifecycle-card"><strong></strong><p></p><AccountDeletionAction requests={deletionRequests} /></article></div></section>
<div className="inline-callout"><div><strong></strong><p> PWA </p></div><Link className="button button-secondary" href="/trust"></Link></div>
</div>
</DashboardShell>
);
}