feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
AccountRecord,
|
||||
AccountDeletionRequestRecord,
|
||||
BetaAccessRequestRecord,
|
||||
BetaRecord,
|
||||
FeedbackRecord,
|
||||
GrantRecord,
|
||||
LaunchGateRecord,
|
||||
RetentionMaintenanceResult,
|
||||
ServiceIncidentRecord,
|
||||
} from "@/db/repository";
|
||||
|
||||
type SubmitState = { loading: boolean; message: string; error: boolean };
|
||||
const idle: SubmitState = { loading: false, message: "", error: false };
|
||||
|
||||
async function postJson<T extends Record<string, unknown> = Record<string, unknown>>(path: string, payload: Record<string, unknown>): Promise<T & { message?: string }> {
|
||||
const response = await fetch(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ...payload, idempotencyKey: crypto.randomUUID() }) });
|
||||
const body = (await response.json()) as T & { message?: string };
|
||||
if (!response.ok) throw new Error(body.message || "操作失败");
|
||||
return body;
|
||||
}
|
||||
|
||||
export function BetaAction({ beta, blockedP0 }: { beta: BetaRecord | null; blockedP0: number }) {
|
||||
const [enabled, setEnabled] = useState(beta?.state === "active");
|
||||
const [capacity, setCapacity] = useState(beta?.capacity_slots?.toString() ?? "");
|
||||
const [reason, setReason] = useState("");
|
||||
const [state, setState] = useState(idle);
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault(); setState({ loading: true, message: "", error: false });
|
||||
try { await postJson("/api/admin/beta", { enabled, capacitySlots: capacity ? Number(capacity) : null, 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 }); }
|
||||
}
|
||||
return <form className="admin-form" onSubmit={submit}><label className="switch-row"><input type="checkbox" checked={enabled} onChange={(event) => setEnabled(event.target.checked)} /><span><strong>全局公测免费政策</strong><small>关闭只停止新的公开配对和未完成认领,不创建订单或付款,也不自动断开既有主机;开启不能绕过 P0。</small></span></label>{blockedP0 > 0 && <p className="form-error" role="status">仍有 {blockedP0} 项 P0 未通过:可以预设免费政策,但新的公开配对继续冻结;只有明确签发的闭测邀请可继续使用。</p>}<label><span>每账户免费主机上限</span><input type="number" min={1} value={capacity} onChange={(event) => setCapacity(event.target.value)} placeholder="留空 = 每账户不按槽位限额" /><small>下调后不会断开既有主机;超出新上限的未完成认领会停止。当前没有时间型宽限设置。</small></label><label><span>变更理由</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} placeholder="为什么现在调整公测政策" required /></label><Submit state={state} label="发布新公测政策版本" /></form>;
|
||||
}
|
||||
|
||||
export function ExemptionAction({ accounts }: { accounts: AccountRecord[] }) {
|
||||
const [accountId, setAccountId] = useState(accounts[0]?.id ?? "");
|
||||
const [capacity, setCapacity] = useState("1");
|
||||
const [endsAt, setEndsAt] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [state, setState] = useState(idle);
|
||||
async function submit(event: React.FormEvent) { event.preventDefault(); setState({ loading: true, message: "", error: false }); try { await postJson("/api/admin/exemptions", { action: "create", accountId, capacitySlots: capacity ? Number(capacity) : null, endsAt: new Date(endsAt).toISOString(), 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 }); } }
|
||||
return <form className="admin-form" onSubmit={submit}><label><span>目标账户</span><select value={accountId} onChange={(event) => setAccountId(event.target.value)} required disabled={!accounts.length}>{accounts.length ? accounts.map((account) => <option value={account.id} key={account.id}>{account.email} · {account.display_name} · {account.id.slice(-8)}</option>) : <option value="">暂无可选账户</option>}</select><small>邮箱只用于核对;邀请始终绑定不可变账户 ID。</small></label><div className="admin-form-grid"><label><span>免费主机槽位</span><input type="number" min={1} value={capacity} onChange={(event) => setCapacity(event.target.value)} placeholder="留空 = 不按槽位限额" /></label><label><span>邀请到期时间</span><input type="datetime-local" value={endsAt} onChange={(event) => setEndsAt(event.target.value)} required /></label></div><label><span>邀请理由</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} placeholder="例如:首批闭测用户,有效至指定日期" required /></label><Submit state={state} label="签发有期限闭测邀请" /></form>;
|
||||
}
|
||||
|
||||
export function AccessRequestAction({ request }: { request: BetaAccessRequestRecord }) {
|
||||
const [action, setAction] = useState<"approve" | "decline">("approve");
|
||||
const [capacity, setCapacity] = useState(request.requested_slots);
|
||||
const [endsAt, setEndsAt] = useState("");
|
||||
const [response, setResponse] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [state, setState] = useState(idle);
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setState({ loading: true, message: "", error: false });
|
||||
try {
|
||||
await postJson("/api/admin/beta-access", {
|
||||
requestId: request.id,
|
||||
action,
|
||||
capacitySlots: capacity,
|
||||
endsAt: action === "approve" ? new Date(endsAt).toISOString() : "",
|
||||
response,
|
||||
reason,
|
||||
});
|
||||
setState({ loading: false, message: action === "approve" ? "申请已批准,免费闭测邀请已签发。" : "申请已拒绝,用户可在控制台看到说明。", error: false });
|
||||
window.setTimeout(() => window.location.reload(), 700);
|
||||
} catch (error) {
|
||||
setState({ loading: false, message: error instanceof Error ? error.message : "操作失败", error: true });
|
||||
}
|
||||
}
|
||||
return (
|
||||
<form className="admin-form" onSubmit={submit}>
|
||||
<div className="admin-form-grid">
|
||||
<label><span>处理结果</span><select value={action} onChange={(event) => setAction(event.target.value as "approve" | "decline")}><option value="approve">批准并签发免费邀请</option><option value="decline">暂不批准</option></select></label>
|
||||
{action === "approve" && <label><span>批准主机数</span><input type="number" min={1} max={3} value={capacity} onChange={(event) => setCapacity(Number(event.target.value))} required /></label>}
|
||||
</div>
|
||||
{action === "approve" && <label><span>邀请到期时间</span><input type="datetime-local" value={endsAt} onChange={(event) => setEndsAt(event.target.value)} required /></label>}
|
||||
<label><span>给用户的说明</span><textarea minLength={2} maxLength={500} value={response} onChange={(event) => setResponse(event.target.value)} placeholder={action === "approve" ? "例如:已开放 1 台主机的免费闭测资格,有效期见资格页。" : "例如:当前测试名额有限,暂未开放;以后可以重新申请。"} required /></label>
|
||||
<label><span>内部处理理由</span><textarea maxLength={500} value={reason} onChange={(event) => setReason(event.target.value)} placeholder="审核依据;仅进入内部审计" required /></label>
|
||||
<Submit state={state} label={action === "approve" ? "批准并签发邀请" : "拒绝申请"} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function InvitationRevokeAction({ invitation }: { invitation: GrantRecord }) {
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [state, setState] = useState(idle);
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setState({ loading: true, message: "", error: false });
|
||||
try {
|
||||
await postJson("/api/admin/exemptions", {
|
||||
action: "revoke",
|
||||
grantId: invitation.id,
|
||||
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 });
|
||||
}
|
||||
}
|
||||
return (
|
||||
<form className="admin-form invitation-revoke-form" onSubmit={submit}>
|
||||
<label><span>撤销理由</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} maxLength={500} required /></label>
|
||||
<label className="switch-row"><input type="checkbox" checked={confirmed} onChange={(event) => setConfirmed(event.target.checked)} /><span><strong>停止该账户后续闭测配对资格</strong><small>未完成的 daemon 认领会失败;已经认领的主机不会自动断开。</small></span></label>
|
||||
<Submit state={state} label="撤销闭测邀请" disabled={!confirmed} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function GateAction({ gates }: { gates: LaunchGateRecord[] }) {
|
||||
const [key, setKey] = useState(gates[0]?.key ?? "");
|
||||
const selected = gates.find((gate) => gate.key === key);
|
||||
const [status, setStatus] = useState<LaunchGateRecord["status"]>(selected?.status ?? "blocked");
|
||||
const [owner, setOwner] = useState(selected?.owner ?? "");
|
||||
const [evidenceUrl, setEvidenceUrl] = useState(selected?.evidence_url ?? "");
|
||||
const [notes, setNotes] = useState(selected?.notes ?? "");
|
||||
const [reason, setReason] = useState("");
|
||||
const [state, setState] = useState(idle);
|
||||
function selectGate(nextKey: string) { const gate = gates.find((item) => item.key === nextKey); setKey(nextKey); setStatus(gate?.status ?? "blocked"); setOwner(gate?.owner ?? ""); setEvidenceUrl(gate?.evidence_url ?? ""); setNotes(gate?.notes ?? ""); }
|
||||
async function submit(event: React.FormEvent) { event.preventDefault(); setState({ loading: true, message: "", error: false }); try { await postJson("/api/admin/launch-gates", { key, status, owner, evidenceUrl, notes, 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 }); } }
|
||||
return <form className="admin-form" onSubmit={submit}><label><span>门禁</span><select value={key} onChange={(event) => selectGate(event.target.value)}>{gates.map((gate) => <option value={gate.key} key={gate.key}>{gate.priority} · {gate.title}</option>)}</select></label><div className="admin-form-grid"><label><span>状态</span><select value={status} onChange={(event) => setStatus(event.target.value as LaunchGateRecord["status"])}><option value="blocked">阻止</option><option value="in_progress">进行中</option><option value="passed">已通过</option><option value="not_applicable">不适用</option></select></label><label><span>负责人</span><input value={owner} onChange={(event) => setOwner(event.target.value)} placeholder="姓名或角色" /></label></div><label><span>证据 URL</span><input type="url" value={evidenceUrl} onChange={(event) => setEvidenceUrl(event.target.value)} placeholder="https://…" /></label><label><span>证据摘要</span><textarea value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="测试范围、日期和结论" /></label><label><span>变更理由</span><textarea value={reason} onChange={(event) => setReason(event.target.value)} required /></label><Submit state={state} label="保存门禁状态" /></form>;
|
||||
}
|
||||
|
||||
export function FeedbackAction({ feedback }: { feedback: FeedbackRecord }) {
|
||||
const [response, setResponse] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [state, setState] = useState(idle);
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setState({ loading: true, message: "", error: false });
|
||||
try {
|
||||
await postJson("/api/admin/feedback", {
|
||||
feedbackId: feedback.id,
|
||||
response,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
return (
|
||||
<form className="admin-form feedback-admin-form" onSubmit={submit}>
|
||||
<label>
|
||||
<span>给用户的回复</span>
|
||||
<textarea
|
||||
minLength={2}
|
||||
maxLength={1000}
|
||||
value={response}
|
||||
onChange={(event) => setResponse(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>内部处理理由</span>
|
||||
<textarea
|
||||
maxLength={500}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="例如:已确认配置问题并给出恢复步骤"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<Submit state={state} label="回复并关闭" />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function IncidentAction() {
|
||||
const [severity, setSeverity] = useState("degraded");
|
||||
const [title, setTitle] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [state, setState] = useState(idle);
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setState({ loading: true, message: "", error: false });
|
||||
try {
|
||||
await postJson("/api/admin/incidents", {
|
||||
action: "create",
|
||||
severity,
|
||||
title,
|
||||
message,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="admin-form" onSubmit={submit}>
|
||||
<label>
|
||||
<span>影响级别</span>
|
||||
<select value={severity} onChange={(event) => setSeverity(event.target.value)}>
|
||||
<option value="maintenance">计划维护</option>
|
||||
<option value="degraded">服务降级</option>
|
||||
<option value="outage">服务中断</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>公开标题</span>
|
||||
<input minLength={4} maxLength={80} value={title} onChange={(event) => setTitle(event.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
<span>给用户的说明</span>
|
||||
<textarea minLength={10} maxLength={1000} value={message} onChange={(event) => setMessage(event.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
<span>内部发布理由</span>
|
||||
<textarea maxLength={500} value={reason} onChange={(event) => setReason(event.target.value)} required />
|
||||
</label>
|
||||
<Submit state={state} label="发布服务公告" />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function IncidentResolveAction({ incident }: { incident: ServiceIncidentRecord }) {
|
||||
const [resolution, setResolution] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [state, setState] = useState(idle);
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setState({ loading: true, message: "", error: false });
|
||||
try {
|
||||
await postJson("/api/admin/incidents", {
|
||||
action: "resolve",
|
||||
incidentId: incident.id,
|
||||
resolution,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="admin-form incident-resolve-form" onSubmit={submit}>
|
||||
<label>
|
||||
<span>公开恢复说明</span>
|
||||
<textarea minLength={4} maxLength={500} value={resolution} onChange={(event) => setResolution(event.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
<span>内部处理理由</span>
|
||||
<textarea maxLength={500} value={reason} onChange={(event) => setReason(event.target.value)} required />
|
||||
</label>
|
||||
<Submit state={state} label="标记恢复并发布说明" />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function RetentionAction() {
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [state, setState] = useState(idle);
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setState({ loading: true, message: "", error: false });
|
||||
try {
|
||||
const body = await postJson<{ retention: RetentionMaintenanceResult }>(
|
||||
"/api/admin/retention",
|
||||
{ confirmed, reason },
|
||||
);
|
||||
const result = body.retention;
|
||||
const total =
|
||||
result.retiredPairingCodes +
|
||||
result.deletedClaimRateWindows +
|
||||
result.deletedClaimAttempts +
|
||||
result.deletedIdempotencyRecords;
|
||||
setState({
|
||||
loading: false,
|
||||
message: `清理完成:处理 ${total} 条到期技术记录,管理审计已追加。`,
|
||||
error: false,
|
||||
});
|
||||
setConfirmed(false);
|
||||
setReason("");
|
||||
} catch (error) {
|
||||
setState({
|
||||
loading: false,
|
||||
message: error instanceof Error ? error.message : "操作失败",
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="admin-form" onSubmit={submit}>
|
||||
<div className="retention-scope-list">
|
||||
<span>过期配对码摘要 → 不可恢复 tombstone</span>
|
||||
<span>来源限速窗口 → 24 小时后删除</span>
|
||||
<span>配对尝试 → 30 天后删除</span>
|
||||
<span>幂等记录 → 自身到期后删除</span>
|
||||
</div>
|
||||
<label>
|
||||
<span>执行理由</span>
|
||||
<textarea
|
||||
maxLength={500}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="例如:执行每周公测数据最小化维护"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="switch-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(event) => setConfirmed(event.target.checked)}
|
||||
/>
|
||||
<span><strong>只处理已经到期的技术记录</strong><small>不会删除账户、主机、反馈、审计、租户卷或备份。</small></span>
|
||||
</label>
|
||||
<Submit state={state} label="执行到期数据清理" disabled={!confirmed} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function PurgeAction({ request }: { request: AccountDeletionRequestRecord }) {
|
||||
const [confirmation, setConfirmation] = useState("");
|
||||
const [reason, setReason] = useState("");
|
||||
const [state, setState] = useState(idle);
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setState({ loading: true, message: "", error: false });
|
||||
try {
|
||||
await postJson("/api/admin/relay-purges", {
|
||||
deletion_request_id: request.id,
|
||||
reason,
|
||||
confirmation,
|
||||
});
|
||||
setState({ loading: false, message: "访问已暂停,Relay 将删除实时数据、附件和全部备份。", error: false });
|
||||
window.setTimeout(() => window.location.reload(), 700);
|
||||
} catch (error) {
|
||||
setState({
|
||||
loading: false,
|
||||
message: error instanceof Error ? error.message : "操作失败",
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="admin-form" onSubmit={submit}>
|
||||
<label>
|
||||
<span>不可撤回的删除理由</span>
|
||||
<textarea minLength={8} maxLength={500} value={reason} onChange={(event) => setReason(event.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
<span>输入 DELETE TENANT DATA 确认</span>
|
||||
<input value={confirmation} onChange={(event) => setConfirmation(event.target.value)} autoComplete="off" required />
|
||||
<small>这会关闭租户 Engine,删除 Relay SQLite、附件、全部备份及活动凭据;不会物理覆写云盘块。</small>
|
||||
</label>
|
||||
<Submit state={state} label="永久逻辑删除 Relay 数据" disabled={confirmation !== "DELETE TENANT DATA" || reason.trim().length < 8} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Submit({ state, label, disabled = false }: { state: SubmitState; label: string; disabled?: boolean }) { return <><button className="button button-primary" type="submit" disabled={state.loading || disabled}>{state.loading ? "正在保存…" : label}</button>{state.message && <p className={state.error ? "form-error" : "form-success"} role="status">{state.message}</p>}</>; }
|
||||
@@ -0,0 +1,283 @@
|
||||
import { requireAdminViewer } from "../cloud-auth";
|
||||
import { DashboardShell, PageHeading, StatusPill, formatDate } from "../components/Shells";
|
||||
import { getAdminSnapshot, type AccountDeletionRequestRecord, type FeedbackCategory } from "@/db/repository";
|
||||
import type { ControlPlaneContactState } from "@/db/device-control-plane";
|
||||
import { deriveInvitationDisplayState } from "@/db/invitations";
|
||||
import {
|
||||
AccessRequestAction,
|
||||
BetaAction,
|
||||
ExemptionAction,
|
||||
FeedbackAction,
|
||||
GateAction,
|
||||
IncidentAction,
|
||||
IncidentResolveAction,
|
||||
InvitationRevokeAction,
|
||||
PurgeAction,
|
||||
RetentionAction,
|
||||
} from "./AdminActions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const feedbackCategoryLabels: Record<FeedbackCategory, string> = {
|
||||
connection_issue: "连接或接入",
|
||||
bug: "功能异常",
|
||||
suggestion: "使用建议",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
const daemonContactCopy: Record<
|
||||
ControlPlaneContactState,
|
||||
{ label: string; tone: "good" | "warn" | "danger" | "neutral" }
|
||||
> = {
|
||||
fresh: { label: "控制面正常", tone: "good" },
|
||||
delayed: { label: "联系延迟", tone: "warn" },
|
||||
stale: { label: "长时间未联系", tone: "danger" },
|
||||
never: { label: "尚未联系", tone: "neutral" },
|
||||
invalid: { label: "时间异常", tone: "danger" },
|
||||
};
|
||||
|
||||
const deletionStatusCopy: Record<
|
||||
AccountDeletionRequestRecord["status"],
|
||||
{ label: string; tone: "good" | "warn" | "danger" | "neutral" }
|
||||
> = {
|
||||
requested: { label: "待核对", tone: "warn" },
|
||||
cancelled: { label: "已撤回", tone: "neutral" },
|
||||
processing: { label: "删除中", tone: "danger" },
|
||||
relay_purged: { label: "Relay 已逻辑删除", tone: "good" },
|
||||
};
|
||||
|
||||
const retentionStatusCopy = {
|
||||
never_run: {
|
||||
label: "尚未自动运行",
|
||||
tone: "warn",
|
||||
detail: "定时入口尚未留下首次执行证据;管理员仍可使用下方手工回退。",
|
||||
},
|
||||
running: {
|
||||
label: "正在清理",
|
||||
tone: "info",
|
||||
detail: "自动任务已经取得单实例运行权,正在处理已到期技术记录。",
|
||||
},
|
||||
healthy: {
|
||||
label: "自动清理正常",
|
||||
tone: "good",
|
||||
detail: "最近一次自动清理在预期时间窗内成功完成。",
|
||||
},
|
||||
overdue: {
|
||||
label: "自动清理逾期",
|
||||
tone: "warn",
|
||||
detail: "最近成功已经超过 36 小时,请核对托管环境的定时触发器。",
|
||||
},
|
||||
failed: {
|
||||
label: "自动清理失败",
|
||||
tone: "danger",
|
||||
detail: "最近一次自动清理失败;错误码已最小化保存,运行时错误会继续进入平台观测。",
|
||||
},
|
||||
stalled: {
|
||||
label: "自动清理卡住",
|
||||
tone: "danger",
|
||||
detail: "任务运行超过 30 分钟;下一次触发可以安全接管,但应先核对 D1 与 Worker 状态。",
|
||||
},
|
||||
invalid: {
|
||||
label: "清理记录异常",
|
||||
tone: "danger",
|
||||
detail: "运行状态包含无效时间、计数或触发来源,不能把它当作成功证据。",
|
||||
},
|
||||
} as const;
|
||||
|
||||
function formatPercent(value: number | null) {
|
||||
return value === null ? "暂无样本" : `${value}%`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null) {
|
||||
if (seconds === null) return "暂无样本";
|
||||
if (seconds < 60) return `${seconds} 秒`;
|
||||
if (seconds < 3600) return `${Math.round(seconds / 60)} 分钟`;
|
||||
if (seconds < 86400) return `${Math.round((seconds / 3600) * 10) / 10} 小时`;
|
||||
return `${Math.round((seconds / 86400) * 10) / 10} 天`;
|
||||
}
|
||||
|
||||
export default async function AdminPage() {
|
||||
const viewer = await requireAdminViewer();
|
||||
const snapshot = await getAdminSnapshot();
|
||||
const deferredPaidGates = snapshot.gates.filter((gate) => gate.priority === "PAID").length;
|
||||
const openFeedback = snapshot.feedback.filter((item) => item.status === "open");
|
||||
const pendingAccessRequests = snapshot.accessRequests.filter((item) => item.status === "requested");
|
||||
const pendingAccessAccountIds = new Set(pendingAccessRequests.map((item) => item.account_id));
|
||||
const proactiveInvitationAccounts = snapshot.accounts.filter((account) => !pendingAccessAccountIds.has(account.id));
|
||||
const invitations = snapshot.grants.filter((grant) => grant.source === "admin_exemption");
|
||||
const activeIncidents = snapshot.incidents.filter((item) => item.status === "active");
|
||||
const pendingDeletionRequests = snapshot.deletionRequests.filter((item) => item.status === "requested");
|
||||
const daemonContactAttention = snapshot.hostContacts.delayed
|
||||
+ snapshot.hostContacts.stale
|
||||
+ snapshot.hostContacts.never
|
||||
+ snapshot.hostContacts.invalid;
|
||||
const daemonContactTone = snapshot.hostContacts.invalid || snapshot.hostContacts.stale
|
||||
? "danger"
|
||||
: daemonContactAttention
|
||||
? "warn"
|
||||
: snapshot.hostContacts.totalActive
|
||||
? "good"
|
||||
: "neutral";
|
||||
const retentionStatus = retentionStatusCopy[snapshot.retention.state];
|
||||
const retentionLastSuccess = snapshot.retention.state === "invalid"
|
||||
? "记录异常"
|
||||
: formatDate(snapshot.retention.lastSuccessAt, true);
|
||||
return (
|
||||
<DashboardShell viewer={viewer} active="/admin">
|
||||
<div className="cloud-page admin-page">
|
||||
<PageHeading eyebrow="PUBLIC BETA ADMIN / 公测后台" title="免费政策、接入资格与上线门禁。" description="当前只运营免费公测。每次管理变更仍需理由、幂等键和追加式审计;报价、订单和价格发布均保持关闭。" />
|
||||
<section className="admin-metrics"><article><span>公测 P0 未通过</span><strong>{snapshot.blockedP0}</strong><small>非零时生产公测开通必须关闭</small></article><article><span>公测政策</span><strong>{snapshot.beta?.state === "active" ? "全部免费" : "已结束"}</strong><small>{snapshot.beta?.capacity_slots === null ? "每账户不按主机槽位限额" : `每账户最多 ${snapshot.beta?.capacity_slots ?? 0} 台主机`}</small></article><article><span>服务状态</span><strong>{activeIncidents.length ? `${activeIncidents.length} 个事件` : "正常"}</strong><small>公开状态与控制台提醒同步</small></article><article><span>待审闭测申请</span><strong>{pendingAccessRequests.length}</strong><small>另有 {openFeedback.length} 条待处理反馈;收费门禁 {deferredPaidGates} 项暂缓</small></article></section>
|
||||
<section className="panel full-panel operations-panel">
|
||||
<div className="panel-heading">
|
||||
<div><span>DAEMON CONTROL PLANE / 即时</span><h2>主机控制面签到</h2></div>
|
||||
<StatusPill tone={daemonContactTone}>{daemonContactAttention ? `${daemonContactAttention} 台需留意` : snapshot.hostContacts.totalActive ? "全部正常" : "暂无主机"}</StatusPill>
|
||||
</div>
|
||||
<p className="operations-intro">从设备凭据最近一次成功 Relay 授权汇总,只证明 daemon 已通过控制面鉴权;不单独证明长连接、重连或 sealed 会话质量。</p>
|
||||
<div className="operations-grid daemon-contact-grid">
|
||||
<article><span>启用主机</span><strong>{snapshot.hostContacts.totalActive}</strong><small>{snapshot.hostContacts.versionUnknown} 台尚未上报 daemon 版本</small></article>
|
||||
<article><span>十分钟内联系</span><strong>{snapshot.hostContacts.fresh}</strong><small>最近成功使用有效设备令牌完成 Relay 授权</small></article>
|
||||
<article><span>联系延迟</span><strong>{snapshot.hostContacts.delayed}</strong><small>十至三十分钟没有再次查询,建议先观察</small></article>
|
||||
<article><span>长时间未联系</span><strong>{snapshot.hostContacts.stale}</strong><small>超过三十分钟,优先核对 daemon 进程、网络和配置</small></article>
|
||||
<article><span>从未联系</span><strong>{snapshot.hostContacts.never}</strong><small>认领后还没有成功完成首次 Relay 授权</small></article>
|
||||
<article><span>时间异常</span><strong>{snapshot.hostContacts.invalid}</strong><small>记录不可解析或明显超前,不能作为活性证据</small></article>
|
||||
</div>
|
||||
<div className="daemon-contact-attention">
|
||||
<h3>需要留意的主机</h3>
|
||||
{snapshot.hostContacts.attentionHosts.length ? (
|
||||
<div className="admin-gate-table">
|
||||
{snapshot.hostContacts.attentionHosts.map((host) => {
|
||||
const state = daemonContactCopy[host.contact_state];
|
||||
return (
|
||||
<article key={host.id}>
|
||||
<span className="gate-priority">{host.os === "windows" ? "W" : "L"}</span>
|
||||
<div><strong>{host.name}</strong><small>{host.email} · {host.id} · {host.daemon_version || "版本待上报"}</small></div>
|
||||
<StatusPill tone={state.tone}>{state.label}</StatusPill>
|
||||
<span>{host.control_plane_last_seen_at ? formatDate(host.control_plane_last_seen_at, true) : "没有成功查询"}</span>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <div className="empty-inline">当前没有需要留意的启用主机。</div>}
|
||||
</div>
|
||||
<p className="operations-footnote">最多列出 25 台非正常主机;汇总包含全部启用主机。最近核对:{formatDate(snapshot.hostContacts.generatedAt, true)}。</p>
|
||||
</section>
|
||||
<section className="panel full-panel operations-panel">
|
||||
<div className="panel-heading">
|
||||
<div><span>ROLLING 30 DAYS / 近 30 天</span><h2>免费公测运营漏斗</h2></div>
|
||||
<StatusPill tone="info">控制平面实数</StatusPill>
|
||||
</div>
|
||||
<p className="operations-intro">只汇总账户、配对、开通任务和站内反馈,不采集原生会话、项目内容或提示词。数字来自当前数据库记录,不代表真实 relay 稳定性。</p>
|
||||
<div className="operations-grid">
|
||||
<article>
|
||||
<span>新增账户</span>
|
||||
<strong>{snapshot.operations.accounts.newAccounts}</strong>
|
||||
<small>累计 {snapshot.operations.accounts.total} 个账户;{snapshot.operations.accounts.withActiveHost} 个已有启用主机</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>闭测申请</span>
|
||||
<strong>{snapshot.operations.accessRequests.submitted}</strong>
|
||||
<small>{snapshot.operations.accessRequests.pending} 条仍待审;{snapshot.operations.accessRequests.cancelled} 条由用户撤回</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>申请批准率</span>
|
||||
<strong>{formatPercent(snapshot.operations.accessRequests.approvalRatePercent)}</strong>
|
||||
<small>{snapshot.operations.accessRequests.approved}/{snapshot.operations.accessRequests.decided} 条已决申请获批;平均审核 {formatDuration(snapshot.operations.accessRequests.averageReviewSeconds)}</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>申请设备需求</span>
|
||||
<strong>{snapshot.operations.accessRequests.requestedSlotDemand} 台</strong>
|
||||
<small>平均 {snapshot.operations.accessRequests.averageRequestedSlots ?? "暂无样本"} 台;Windows {snapshot.operations.accessRequests.windows}、Linux {snapshot.operations.accessRequests.linux}、双系统 {snapshot.operations.accessRequests.both}</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>获批后认领率</span>
|
||||
<strong>{formatPercent(snapshot.operations.accessRequests.postApprovalClaimRatePercent)}</strong>
|
||||
<small>{snapshot.operations.accessRequests.approvedWithPostApprovalClaim}/{snapshot.operations.accessRequests.approved} 条获批申请在获批后至少认领过一台主机;不代表 relay 可用</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>配对认领率</span>
|
||||
<strong>{formatPercent(snapshot.operations.pairings.claimRatePercent)}</strong>
|
||||
<small>{snapshot.operations.pairings.claimed}/{snapshot.operations.pairings.created} 个新请求已认领;平均 {formatDuration(snapshot.operations.pairings.averageClaimSeconds)}</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>配对异常信号</span>
|
||||
<strong>{snapshot.operations.pairings.attentionRequired}</strong>
|
||||
<small>{snapshot.operations.pairings.waiting} 个仍可认领;{snapshot.operations.pairings.expired} 个过期、{snapshot.operations.pairings.locked} 个锁定;另有 {snapshot.operations.pairings.rejectedAttempts} 次拒绝、{snapshot.operations.pairings.rateLimitedAttempts} 次限速</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>Relay placement 就绪率</span>
|
||||
<strong>{formatPercent(snapshot.operations.provisioning.successRatePercent)}</strong>
|
||||
<small>{snapshot.operations.provisioning.succeeded}/{snapshot.operations.provisioning.created} 个 placement 已进入 active;平均 {formatDuration(snapshot.operations.provisioning.averageCompletionSeconds)}</small>
|
||||
</article>
|
||||
<article>
|
||||
<span>反馈解决率</span>
|
||||
<strong>{formatPercent(snapshot.operations.support.resolutionRatePercent)}</strong>
|
||||
<small>{snapshot.operations.support.resolved}/{snapshot.operations.support.created} 条已回复;{snapshot.operations.support.connectionIssues} 条属于连接问题,平均周转 {formatDuration(snapshot.operations.support.averageResolutionSeconds)}</small>
|
||||
</article>
|
||||
<article className="operations-unavailable">
|
||||
<span>尚不可测</span>
|
||||
<strong>真实中继</strong>
|
||||
<small>重连成功率、relay 延迟、运行时资源成本和实际支持工时,等真实租户接通后再采集</small>
|
||||
</article>
|
||||
</div>
|
||||
<p className="operations-footnote">口径:账户总数和“已有启用主机”是当前累计值,其余均按记录创建时间滚动统计近 {snapshot.operations.windowDays} 天;处理中记录不会被算作成功。最近核对:{formatDate(snapshot.operations.generatedAt, true)}。</p>
|
||||
</section>
|
||||
<div className="admin-grid">
|
||||
<section className="panel admin-module"><div className="panel-heading"><div><span>版本化政策</span><h2>全局公测免费</h2></div><StatusPill tone={snapshot.beta?.state === "active" && snapshot.blockedP0 === 0 ? "good" : snapshot.beta?.state === "active" ? "warn" : "neutral"}>{snapshot.beta?.state === "active" && snapshot.blockedP0 > 0 ? "政策已设 / 接入冻结" : snapshot.beta?.state ?? "未设置"}</StatusPill></div><BetaAction beta={snapshot.beta} blockedP0={snapshot.blockedP0} /></section>
|
||||
<section className="panel admin-module"><div className="panel-heading"><div><span>暂缓施工</span><h2>收费功能以后再做</h2></div><StatusPill tone="neutral">未开放</StatusPill></div><p>当前不发布价格、不生成报价、不创建订单。等用户规模、资源成本和支持负担有真实数据后,再重新做收费决策。</p></section>
|
||||
<section className="panel admin-module"><div className="panel-heading"><div><span>主动邀请 / 非货币权益</span><h2>签发闭测邀请</h2></div></div><p>这里只给没有待审申请的账户主动发邀请。已有申请必须在下方队列批准或拒绝,避免用户同时看到“已有资格”和“仍在审核”。邀请不创建金额、余额或未来付费关系。</p><ExemptionAction accounts={proactiveInvitationAccounts} /></section>
|
||||
<section className="panel admin-module">
|
||||
<div className="panel-heading">
|
||||
<div><span>DATA MINIMIZATION</span><h2>到期技术数据清理</h2></div>
|
||||
<StatusPill tone={retentionStatus.tone}>{retentionStatus.label}</StatusPill>
|
||||
</div>
|
||||
<p>每天北京时间 02:17 自动处理已经到期的技术记录;手工入口仅作恢复回退,不扩大删除范围。</p>
|
||||
<div className="maintenance-status" aria-label="到期数据自动清理状态">
|
||||
<div><span>最近自动成功</span><strong>{retentionLastSuccess}</strong></div>
|
||||
<div><span>自动运行次数</span><strong>{snapshot.retention.runCount}</strong></div>
|
||||
<div><span>连续失败</span><strong>{snapshot.retention.consecutiveFailures}</strong></div>
|
||||
</div>
|
||||
<p className="maintenance-status-detail">
|
||||
{retentionStatus.detail}
|
||||
{snapshot.retention.ageSeconds !== null ? ` 距相关状态约 ${formatDuration(snapshot.retention.ageSeconds)}。` : ""}
|
||||
{snapshot.retention.errorCode ? ` 错误码:${snapshot.retention.errorCode}。` : ""}
|
||||
</p>
|
||||
<RetentionAction />
|
||||
</section>
|
||||
<section className="panel admin-module"><div className="panel-heading"><div><span>证据驱动</span><h2>更新上线门禁</h2></div></div><GateAction gates={snapshot.gates} /></section>
|
||||
</div>
|
||||
<section className="panel full-panel"><div className="panel-heading"><div><span>CLOSED BETA REQUESTS</span><h2>免费闭测申请队列</h2></div><StatusPill tone={pendingAccessRequests.length ? "warn" : "good"}>{pendingAccessRequests.length ? `${pendingAccessRequests.length} 条待处理` : "已清空"}</StatusPill></div><p>批准申请会在同一数据库动作中签发 1–3 台、有期限的非货币邀请;拒绝不会创建权益。申请顺序不代表承诺或优先级。</p><div className="admin-feedback-list">{snapshot.accessRequests.length ? snapshot.accessRequests.map((request) => { const account = snapshot.accounts.find((item) => item.id === request.account_id); return <article key={request.id}><div className="feedback-meta"><StatusPill tone={request.status === "approved" ? "good" : request.status === "requested" ? "warn" : "neutral"}>{request.status === "approved" ? "已批准" : request.status === "requested" ? "待处理" : request.status === "declined" ? "未批准" : "用户已撤回"}</StatusPill><span>{account?.email ?? request.account_id}</span><span>{request.requested_slots} 台 · {request.preferred_os}</span><span>{formatDate(request.requested_at, true)}</span></div><p>{request.use_case}</p>{request.admin_response && <div className="feedback-response"><strong>给用户的说明</strong><p>{request.admin_response}</p></div>}<code>{request.id}</code>{request.status === "requested" && <AccessRequestAction request={request} />}</article>; }) : <div className="empty-inline">还没有闭测申请。</div>}</div></section>
|
||||
<section className="panel full-panel"><div className="panel-heading"><div><span>CLOSED BETA INVITATIONS</span><h2>闭测邀请记录</h2></div><StatusPill tone={invitations.some((grant) => deriveInvitationDisplayState(grant) === "active") ? "good" : "neutral"}>{invitations.filter((grant) => deriveInvitationDisplayState(grant) === "active").length} 个有效</StatusPill></div><p>撤销只阻止该邀请继续创建或认领新配对;既有主机的处置走独立撤销流程。</p><div className="admin-feedback-list">{invitations.length ? invitations.map((invitation) => { const state = deriveInvitationDisplayState(invitation); const account = snapshot.accounts.find((item) => item.id === invitation.account_id); return <article key={invitation.id}><div className="feedback-meta"><StatusPill tone={state === "active" ? "good" : "neutral"}>{state === "active" ? "有效" : state === "expired" ? "已到期" : "已撤销"}</StatusPill><span>{account?.email ?? invitation.account_id}</span><span>{invitation.capacity_slots === null ? "不按槽位限额" : `${invitation.capacity_slots} 个槽位`}</span><span>至 {formatDate(invitation.ends_at, true)}</span></div><p>{invitation.reason}</p><code>{invitation.id}</code>{state === "active" && <InvitationRevokeAction invitation={invitation} />}</article>; }) : <div className="empty-inline">还没有闭测邀请。</div>}</div></section>
|
||||
<section className="panel full-panel">
|
||||
<div className="panel-heading">
|
||||
<div><span>ACCOUNT LIFECYCLE</span><h2>注销申请队列</h2></div>
|
||||
<StatusPill tone={pendingDeletionRequests.length ? "warn" : "good"}>
|
||||
{pendingDeletionRequests.length ? `${pendingDeletionRequests.length} 条待核对` : "无待核对"}
|
||||
</StatusPill>
|
||||
</div>
|
||||
<p>只有已确认的申请才能启动永久逻辑删除。Relay 数据删除完成不等于账户身份、法定保留记录和云存储物理块已经全部清除。</p>
|
||||
<div className="admin-deletion-list">
|
||||
{snapshot.deletionRequests.length ? snapshot.deletionRequests.map((request) => {
|
||||
const copy = deletionStatusCopy[request.status];
|
||||
return (
|
||||
<article key={request.id}>
|
||||
<div>
|
||||
<StatusPill tone={copy.tone}>{copy.label}</StatusPill>
|
||||
<strong>{snapshot.accounts.find((account) => account.id === request.account_id)?.email ?? request.account_id}</strong>
|
||||
<span>{formatDate(request.requested_at, true)}</span>
|
||||
</div>
|
||||
<p>{request.reason || "用户未填写补充说明。"}</p>
|
||||
<code>{request.id}</code>
|
||||
{request.status === "requested" && <PurgeAction request={request} />}
|
||||
</article>
|
||||
);
|
||||
}) : <div className="empty-inline">还没有注销申请。</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="panel full-panel"><div className="panel-heading"><div><span>STATUS & INCIDENTS</span><h2>服务状态与故障公告</h2></div><StatusPill tone={activeIncidents.length ? "danger" : "good"}>{activeIncidents.length ? `${activeIncidents.length} 个处理中` : "服务正常"}</StatusPill></div><div className="admin-incident-layout"><div><h3>发布新公告</h3><p>只发布会影响用户操作的信息;公开说明不会替代内部日志和异常告警。</p><IncidentAction /></div><div className="admin-incident-list"><h3>事件记录</h3>{snapshot.incidents.length ? snapshot.incidents.map((incident) => <article key={incident.id}><div className="incident-card-heading"><StatusPill tone={incident.status === "resolved" ? "good" : incident.severity === "outage" ? "danger" : incident.severity === "degraded" ? "warn" : "info"}>{incident.status === "resolved" ? "已恢复" : incident.severity === "outage" ? "服务中断" : incident.severity === "degraded" ? "服务降级" : "计划维护"}</StatusPill><span>{formatDate(incident.started_at, true)}</span></div><h4>{incident.title}</h4><p>{incident.message}</p>{incident.status === "active" ? <IncidentResolveAction incident={incident} /> : <div className="feedback-response"><strong>恢复说明</strong><p>{incident.resolution}</p></div>}</article>) : <div className="empty-inline">还没有服务事件。</div>}</div></div></section>
|
||||
<section className="panel full-panel"><div className="panel-heading"><div><span>FREE BETA SUPPORT</span><h2>公测反馈处理</h2></div><StatusPill tone={openFeedback.length ? "warn" : "good"}>{openFeedback.length ? `${openFeedback.length} 条待处理` : "已清空"}</StatusPill></div>{snapshot.feedback.length ? <div className="admin-feedback-list">{snapshot.feedback.map((item) => <article key={item.id}><div className="feedback-meta"><StatusPill tone={item.status === "resolved" ? "good" : "warn"}>{item.status === "resolved" ? "已回复" : "待处理"}</StatusPill><span>{feedbackCategoryLabels[item.category]}</span><span>{formatDate(item.created_at, true)}</span><span>{snapshot.accounts.find((account) => account.id === item.account_id)?.email ?? item.account_id}</span></div><p>{item.message}</p>{item.admin_response && <div className="feedback-response"><strong>已回复</strong><p>{item.admin_response}</p></div>}{item.status === "open" && <FeedbackAction feedback={item} />}</article>)}</div> : <div className="empty-inline">还没有用户反馈。</div>}</section>
|
||||
<section className="panel full-panel"><div className="panel-heading"><div><span>P0 / P1 / PAID</span><h2>门禁总表</h2></div></div><div className="admin-gate-table">{snapshot.gates.map((gate) => <article key={gate.key}><span className="gate-priority">{gate.priority}</span><div><strong>{gate.title}</strong><small>{gate.category} · {gate.key}</small></div><StatusPill tone={gate.priority === "PAID" ? "neutral" : gate.status === "passed" ? "good" : gate.status === "in_progress" ? "warn" : "danger"}>{gate.priority === "PAID" ? "以后处理" : gate.status}</StatusPill><span>{gate.owner || "待指定"}</span></article>)}</div></section>
|
||||
<section className="panel full-panel"><div className="panel-heading"><div><span>APPEND ONLY</span><h2>最近管理审计</h2></div></div><div className="audit-list">{snapshot.audits.length ? snapshot.audits.map((audit) => <article key={audit.id}><span>{formatDate(audit.created_at, true)}</span><strong>{audit.action}</strong><span>{audit.target_type} / {audit.target_id}</span><p>{audit.reason}</p><code>{audit.actor_id}</code></article>) : <div className="empty-inline">还没有管理审计事件。</div>}</div></section>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import {
|
||||
cancelAccountDeletion,
|
||||
DomainError,
|
||||
getOrCreateAccount,
|
||||
requestAccountDeletion,
|
||||
} from "@/db/repository";
|
||||
import { apiError, readJsonMutation } from "../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) {
|
||||
return Response.json(
|
||||
{ error: "authentication_required", message: "请先登录" },
|
||||
{ status: 401, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
}
|
||||
const payload = await readJsonMutation<{
|
||||
action?: string;
|
||||
requestId?: string;
|
||||
confirmed?: boolean;
|
||||
reason?: string;
|
||||
idempotencyKey?: string;
|
||||
}>(request);
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
|
||||
if (payload.action === "request") {
|
||||
const deletionRequest = await requestAccountDeletion({
|
||||
accountId: account.id,
|
||||
actorId: viewer.userId,
|
||||
confirmed: payload.confirmed === true,
|
||||
reason: payload.reason ?? "",
|
||||
idempotencyKey: payload.idempotencyKey ?? "",
|
||||
});
|
||||
return Response.json(
|
||||
{ deletionRequest },
|
||||
{ status: 201, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
}
|
||||
if (payload.action === "cancel") {
|
||||
const deletionRequest = await cancelAccountDeletion({
|
||||
accountId: account.id,
|
||||
actorId: viewer.userId,
|
||||
requestId: payload.requestId ?? "",
|
||||
});
|
||||
return Response.json(
|
||||
{ deletionRequest },
|
||||
{ headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
}
|
||||
throw new DomainError(
|
||||
"invalid_deletion_action",
|
||||
"请选择有效的注销申请操作",
|
||||
);
|
||||
} catch (error) {
|
||||
const response = apiError(error);
|
||||
response.headers.set("cache-control", "no-store");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import {
|
||||
getAccountControlPlaneExport,
|
||||
getOrCreateAccount,
|
||||
} from "@/db/repository";
|
||||
import { apiError } from "../../respond";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) {
|
||||
return Response.json(
|
||||
{ error: "authentication_required", message: "请先登录" },
|
||||
{ status: 401, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
}
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
const exportData = await getAccountControlPlaneExport(account);
|
||||
const date = exportData.exported_at.slice(0, 10);
|
||||
return new Response(`${JSON.stringify(exportData, null, 2)}\n`, {
|
||||
headers: {
|
||||
"cache-control": "no-store",
|
||||
"content-disposition": `attachment; filename="nekonest-cloud-${date}.json"`,
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"x-content-type-options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const response = apiError(error);
|
||||
response.headers.set("cache-control", "no-store");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import { resolveBetaAccessRequest } from "@/db/repository";
|
||||
import { apiError, readJsonMutation } from "../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) {
|
||||
return Response.json(
|
||||
{ error: "authentication_required", message: "请先登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
if (!viewer.isAdmin) {
|
||||
return Response.json(
|
||||
{ error: "forbidden", message: "没有商业后台权限" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const payload = await readJsonMutation<{
|
||||
requestId?: string;
|
||||
action?: "approve" | "decline";
|
||||
capacitySlots?: number;
|
||||
endsAt?: string;
|
||||
response?: string;
|
||||
reason?: string;
|
||||
idempotencyKey?: string;
|
||||
}>(request);
|
||||
const result = await resolveBetaAccessRequest({
|
||||
actorId: viewer.userId,
|
||||
requestId: payload.requestId ?? "",
|
||||
action: payload.action ?? "decline",
|
||||
capacitySlots: payload.capacitySlots ?? 0,
|
||||
endsAt: payload.endsAt ?? "",
|
||||
response: payload.response ?? "",
|
||||
reason: payload.reason ?? "",
|
||||
idempotencyKey: payload.idempotencyKey ?? "",
|
||||
});
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import { setPublicBeta } from "@/db/repository";
|
||||
import { apiError, readJsonMutation } from "../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) return Response.json({ error: "authentication_required", message: "请先登录" }, { status: 401 });
|
||||
if (!viewer.isAdmin) return Response.json({ error: "forbidden", message: "没有商业后台权限" }, { status: 403 });
|
||||
const payload = await readJsonMutation<{ enabled?: boolean; capacitySlots?: number | null; reason?: string; idempotencyKey?: string }>(request);
|
||||
const beta = await setPublicBeta({ actorId: viewer.userId, enabled: payload.enabled ?? false, capacitySlots: payload.capacitySlots ?? null, reason: payload.reason ?? "", idempotencyKey: payload.idempotencyKey ?? "" });
|
||||
return Response.json({ beta });
|
||||
} catch (error) { return apiError(error); }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import { createExemption, revokeExemption } from "@/db/repository";
|
||||
import { apiError, readJsonMutation } from "../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) return Response.json({ error: "authentication_required", message: "请先登录" }, { status: 401 });
|
||||
if (!viewer.isAdmin) return Response.json({ error: "forbidden", message: "没有商业后台权限" }, { status: 403 });
|
||||
const payload = await readJsonMutation<{ action?: string; grantId?: string; accountId?: string; capacitySlots?: number | null; endsAt?: string; reason?: string; idempotencyKey?: string }>(request);
|
||||
if (payload.action === "revoke") {
|
||||
const grant = await revokeExemption({ actorId: viewer.userId, grantId: payload.grantId ?? "", reason: payload.reason ?? "", idempotencyKey: payload.idempotencyKey ?? "" });
|
||||
return Response.json({ grant });
|
||||
}
|
||||
const grant = await createExemption({ actorId: viewer.userId, accountId: payload.accountId ?? "", capacitySlots: payload.capacitySlots ?? null, endsAt: payload.endsAt ?? "", reason: payload.reason ?? "", idempotencyKey: payload.idempotencyKey ?? "" });
|
||||
return Response.json({ grant }, { status: 201 });
|
||||
} catch (error) { return apiError(error); }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import { resolveFeedback } from "@/db/repository";
|
||||
import { apiError, readJsonMutation } from "../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) {
|
||||
return Response.json(
|
||||
{ error: "authentication_required", message: "请先登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
if (!viewer.isAdmin) {
|
||||
return Response.json(
|
||||
{ error: "forbidden", message: "没有公测后台权限" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const payload = await readJsonMutation<{
|
||||
feedbackId?: string;
|
||||
response?: string;
|
||||
reason?: string;
|
||||
idempotencyKey?: string;
|
||||
}>(request);
|
||||
const feedback = await resolveFeedback({
|
||||
feedbackId: payload.feedbackId ?? "",
|
||||
actorId: viewer.userId,
|
||||
response: payload.response ?? "",
|
||||
reason: payload.reason ?? "",
|
||||
idempotencyKey: payload.idempotencyKey ?? "",
|
||||
});
|
||||
return Response.json({ feedback });
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import {
|
||||
createServiceIncident,
|
||||
resolveServiceIncident,
|
||||
} from "@/db/repository";
|
||||
import { apiError, readJsonMutation } from "../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) {
|
||||
return Response.json(
|
||||
{ error: "authentication_required", message: "请先登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
if (!viewer.isAdmin) {
|
||||
return Response.json(
|
||||
{ error: "forbidden", message: "没有公测后台权限" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const payload = await readJsonMutation<{
|
||||
action?: string;
|
||||
incidentId?: string;
|
||||
severity?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
resolution?: string;
|
||||
reason?: string;
|
||||
idempotencyKey?: string;
|
||||
}>(request);
|
||||
|
||||
if (payload.action === "create") {
|
||||
const incident = await createServiceIncident({
|
||||
actorId: viewer.userId,
|
||||
severity: payload.severity ?? "",
|
||||
title: payload.title ?? "",
|
||||
message: payload.message ?? "",
|
||||
reason: payload.reason ?? "",
|
||||
idempotencyKey: payload.idempotencyKey ?? "",
|
||||
});
|
||||
return Response.json({ incident }, { status: 201 });
|
||||
}
|
||||
if (payload.action === "resolve") {
|
||||
const incident = await resolveServiceIncident({
|
||||
incidentId: payload.incidentId ?? "",
|
||||
actorId: viewer.userId,
|
||||
resolution: payload.resolution ?? "",
|
||||
reason: payload.reason ?? "",
|
||||
});
|
||||
return Response.json({ incident });
|
||||
}
|
||||
return Response.json(
|
||||
{ error: "invalid_incident_action", message: "请选择有效的故障公告操作" },
|
||||
{ status: 400 },
|
||||
);
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import { updateLaunchGate, type LaunchGateRecord } from "@/db/repository";
|
||||
import { apiError, readJsonMutation } from "../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) return Response.json({ error: "authentication_required", message: "请先登录" }, { status: 401 });
|
||||
if (!viewer.isAdmin) return Response.json({ error: "forbidden", message: "没有商业后台权限" }, { status: 403 });
|
||||
const payload = await readJsonMutation<{ key?: string; status?: LaunchGateRecord["status"]; owner?: string; evidenceUrl?: string; notes?: string; reason?: string; idempotencyKey?: string }>(request);
|
||||
const gate = await updateLaunchGate({ actorId: viewer.userId, key: payload.key ?? "", status: payload.status ?? "blocked", owner: payload.owner ?? "", evidenceUrl: payload.evidenceUrl ?? "", notes: payload.notes ?? "", reason: payload.reason ?? "", idempotencyKey: payload.idempotencyKey ?? "" });
|
||||
return Response.json({ gate });
|
||||
} catch (error) { return apiError(error); }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import { DomainError } from "@/db/repository";
|
||||
import { apiError, readJsonMutation } from "../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) return Response.json({ error: "authentication_required", message: "请先登录" }, { status: 401 });
|
||||
if (!viewer.isAdmin) return Response.json({ error: "forbidden", message: "没有商业后台权限" }, { status: 403 });
|
||||
await readJsonMutation<{ period?: "month" | "year"; amountMinor?: number; reason?: string; idempotencyKey?: string }>(request);
|
||||
throw new DomainError("paid_features_deferred", "免费公测阶段不发布价格版本", 409);
|
||||
} catch (error) { return apiError(error); }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import { beginRelayMigration } from "@/db/relay-migrations";
|
||||
import { apiError, readJsonMutation } from "../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) {
|
||||
return Response.json(
|
||||
{ error_code: "authentication_required", message: "请先登录", retryable: false },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
if (!viewer.isAdmin) {
|
||||
return Response.json(
|
||||
{ error_code: "forbidden", message: "没有 Relay 迁移权限", retryable: false },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const payload = await readJsonMutation<{
|
||||
tenant_id?: string;
|
||||
target_node_id?: string;
|
||||
reason?: string;
|
||||
idempotency_key?: string;
|
||||
}>(request);
|
||||
const migration = await beginRelayMigration({
|
||||
tenantId: payload.tenant_id ?? "",
|
||||
targetNodeId: payload.target_node_id ?? "",
|
||||
actorId: viewer.userId,
|
||||
reason: payload.reason ?? "",
|
||||
idempotencyKey: payload.idempotency_key ?? "",
|
||||
});
|
||||
return Response.json(
|
||||
{ migration_id: migration.id, state: migration.state, started_at: migration.started_at },
|
||||
{ status: 202, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import { beginRelayPurge } from "@/db/relay-purges";
|
||||
import { apiError, readJsonMutation } from "../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) {
|
||||
return Response.json(
|
||||
{ error_code: "authentication_required", message: "请先登录", retryable: false },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
if (!viewer.isAdmin) {
|
||||
return Response.json(
|
||||
{ error_code: "forbidden", message: "没有永久删除租户数据的权限", retryable: false },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
const payload = await readJsonMutation<{
|
||||
deletion_request_id?: string;
|
||||
reason?: string;
|
||||
confirmation?: string;
|
||||
}>(request);
|
||||
const purge = await beginRelayPurge({
|
||||
deletionRequestId: payload.deletion_request_id ?? "",
|
||||
actorId: viewer.userId,
|
||||
reason: payload.reason ?? "",
|
||||
confirmation: payload.confirmation ?? "",
|
||||
});
|
||||
return Response.json(
|
||||
{ purge_id: purge.id, state: purge.state, started_at: purge.started_at },
|
||||
{ status: 202, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import { runRetentionMaintenance } from "@/db/repository";
|
||||
import { apiError, readJsonMutation } from "../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) {
|
||||
return Response.json(
|
||||
{ error: "authentication_required", message: "请先登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
if (!viewer.isAdmin) {
|
||||
return Response.json(
|
||||
{ error: "forbidden", message: "没有公测后台权限" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const payload = await readJsonMutation<{
|
||||
confirmed?: boolean;
|
||||
reason?: string;
|
||||
}>(request);
|
||||
const retention = await runRetentionMaintenance({
|
||||
actorId: viewer.userId,
|
||||
confirmed: payload.confirmed === true,
|
||||
reason: payload.reason ?? "",
|
||||
});
|
||||
return Response.json({ retention });
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import {
|
||||
cancelBetaAccessRequest,
|
||||
createBetaAccessRequest,
|
||||
getOrCreateAccount,
|
||||
} from "@/db/repository";
|
||||
import { apiError, readJsonMutation } from "../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) {
|
||||
return Response.json(
|
||||
{ error: "authentication_required", message: "请先登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
const payload = await readJsonMutation<{
|
||||
action?: string;
|
||||
requestId?: string;
|
||||
preferredOs?: string;
|
||||
requestedSlots?: number;
|
||||
useCase?: string;
|
||||
idempotencyKey?: string;
|
||||
}>(request);
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
if (payload.action === "cancel") {
|
||||
const accessRequest = await cancelBetaAccessRequest({
|
||||
accountId: account.id,
|
||||
actorId: viewer.userId,
|
||||
requestId: payload.requestId ?? "",
|
||||
idempotencyKey: payload.idempotencyKey ?? "",
|
||||
});
|
||||
return Response.json({ accessRequest });
|
||||
}
|
||||
if (payload.action !== "request") {
|
||||
return Response.json(
|
||||
{ error: "invalid_action", message: "请选择提交或撤回闭测申请" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const accessRequest = await createBetaAccessRequest({
|
||||
accountId: account.id,
|
||||
actorId: viewer.userId,
|
||||
preferredOs: payload.preferredOs ?? "",
|
||||
requestedSlots: payload.requestedSlots ?? 0,
|
||||
useCase: payload.useCase ?? "",
|
||||
idempotencyKey: payload.idempotencyKey ?? "",
|
||||
});
|
||||
return Response.json({ accessRequest }, { status: 201 });
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import { DomainError } from "@/db/repository";
|
||||
import { apiError, readJsonMutation } from "../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) {
|
||||
return Response.json(
|
||||
{ error: "authentication_required", message: "请先登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
await readJsonMutation<{
|
||||
period?: "month" | "year";
|
||||
quantity?: number;
|
||||
idempotencyKey?: string;
|
||||
}>(request);
|
||||
throw new DomainError(
|
||||
"paid_features_deferred",
|
||||
"免费公测阶段不提供报价或订单;未来收费方案确定后会另行通知",
|
||||
409,
|
||||
);
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import { createFeedback, getOrCreateAccount } from "@/db/repository";
|
||||
import { apiError, readJsonMutation } from "../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) {
|
||||
return Response.json(
|
||||
{ error: "authentication_required", message: "请先登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
const payload = await readJsonMutation<{
|
||||
category?: string;
|
||||
message?: string;
|
||||
idempotencyKey?: string;
|
||||
}>(request);
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
const feedback = await createFeedback({
|
||||
accountId: account.id,
|
||||
actorId: viewer.userId,
|
||||
category: payload.category ?? "",
|
||||
message: payload.message ?? "",
|
||||
idempotencyKey: payload.idempotencyKey ?? "",
|
||||
});
|
||||
return Response.json({ feedback }, { status: 201 });
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import {
|
||||
cancelPairingRequest,
|
||||
getOrCreateAccount,
|
||||
} from "@/db/repository";
|
||||
import { apiError, readJsonMutation } from "../../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) {
|
||||
return Response.json(
|
||||
{ error: "authentication_required", message: "请先登录" },
|
||||
{ status: 401, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
}
|
||||
const payload = await readJsonMutation<{ pairingId?: string }>(request);
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
const result = await cancelPairingRequest({
|
||||
accountId: account.id,
|
||||
pairingId: payload.pairingId ?? "",
|
||||
actorId: viewer.userId,
|
||||
});
|
||||
return Response.json(result, {
|
||||
status: 200,
|
||||
headers: { "cache-control": "no-store" },
|
||||
});
|
||||
} catch (error) {
|
||||
const response = apiError(error);
|
||||
response.headers.set("cache-control", "no-store");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import {
|
||||
createPairingRequest,
|
||||
getOrCreateAccount,
|
||||
getOwnedPairingProgress,
|
||||
} from "@/db/repository";
|
||||
import { apiError, readJsonMutation } from "../../respond";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) {
|
||||
return Response.json(
|
||||
{ error: "authentication_required", message: "请先登录" },
|
||||
{ status: 401, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
}
|
||||
const pairingId = new URL(request.url).searchParams.get("pairing_id") ?? "";
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
const pairing = await getOwnedPairingProgress({
|
||||
accountId: account.id,
|
||||
pairingId,
|
||||
});
|
||||
return Response.json(
|
||||
{ pairing },
|
||||
{ status: 200, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
const response = apiError(error);
|
||||
response.headers.set("cache-control", "no-store");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) {
|
||||
return Response.json(
|
||||
{ error: "authentication_required", message: "请先登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
const payload = await readJsonMutation<{
|
||||
requestedName?: string;
|
||||
os?: "windows" | "linux";
|
||||
}>(request);
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
const pairing = await createPairingRequest({
|
||||
accountId: account.id,
|
||||
requestedName: payload.requestedName ?? "",
|
||||
os: payload.os ?? "windows",
|
||||
actorId: viewer.userId,
|
||||
});
|
||||
return Response.json(
|
||||
{ pairing },
|
||||
{ status: 201, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
const response = apiError(error);
|
||||
response.headers.set("cache-control", "no-store");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import { getOrCreateAccount, revokeHost } from "@/db/repository";
|
||||
import { apiError, readJsonMutation } from "../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) {
|
||||
return Response.json(
|
||||
{ error: "authentication_required", message: "请先登录" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
const payload = await readJsonMutation<{ hostId?: string; reason?: string }>(
|
||||
request,
|
||||
);
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
const result = await revokeHost({
|
||||
accountId: account.id,
|
||||
hostId: payload.hostId ?? "",
|
||||
actorId: viewer.userId,
|
||||
reason: payload.reason ?? "用户从控制台撤销主机",
|
||||
});
|
||||
return Response.json(result);
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
authenticateRelayNode,
|
||||
authorizationRevisionDelta,
|
||||
} from "@/db/relay-control-plane";
|
||||
import { apiError, readJsonMutation } from "../../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const principal = await authenticateRelayNode(request);
|
||||
const payload = await readJsonMutation<{
|
||||
tenant_id?: string;
|
||||
after_revision?: number;
|
||||
}>(request);
|
||||
return Response.json(
|
||||
await authorizationRevisionDelta({
|
||||
principal,
|
||||
tenantId: payload.tenant_id ?? "",
|
||||
afterRevision: Number(payload.after_revision ?? -1),
|
||||
}),
|
||||
{ headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
authenticateRelayNode,
|
||||
fullAuthorizationSnapshot,
|
||||
} from "@/db/relay-control-plane";
|
||||
import { apiError, readJsonMutation } from "../../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const principal = await authenticateRelayNode(request);
|
||||
const payload = await readJsonMutation<{
|
||||
tenant_id?: string;
|
||||
placement_generation?: number;
|
||||
}>(request);
|
||||
return Response.json(await fullAuthorizationSnapshot({
|
||||
principal,
|
||||
tenantId: payload.tenant_id ?? "",
|
||||
placementGeneration: Number(payload.placement_generation ?? -1),
|
||||
}), { headers: { "cache-control": "no-store" } });
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
authenticateRelayNode,
|
||||
authorizeDeviceForRelay,
|
||||
} from "@/db/relay-control-plane";
|
||||
import { apiError, readJsonMutation } from "../../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const principal = await authenticateRelayNode(request);
|
||||
const payload = await readJsonMutation<{
|
||||
device_id?: string;
|
||||
token_hash?: string;
|
||||
}>(request);
|
||||
const result = await authorizeDeviceForRelay({
|
||||
principal,
|
||||
deviceId: payload.device_id ?? "",
|
||||
tokenHash: payload.token_hash?.trim().toLowerCase() ?? "",
|
||||
});
|
||||
return Response.json(result, { headers: { "cache-control": "no-store" } });
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
authenticateRelayNode,
|
||||
authorizePhoneRoute,
|
||||
} from "@/db/relay-control-plane";
|
||||
import { apiError, readJsonMutation } from "../../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const principal = await authenticateRelayNode(request);
|
||||
const payload = await readJsonMutation<{
|
||||
route_handle?: string;
|
||||
phone_token_hash?: string;
|
||||
}>(request);
|
||||
return Response.json(
|
||||
await authorizePhoneRoute({
|
||||
principal,
|
||||
routeHandle: payload.route_handle ?? "",
|
||||
phoneTokenHash: payload.phone_token_hash ?? "",
|
||||
}),
|
||||
{ headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
authenticateRelayNode,
|
||||
completePhoneHandoffForRelay,
|
||||
} from "@/db/relay-control-plane";
|
||||
import { apiError, readJsonMutation } from "../../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const principal = await authenticateRelayNode(request);
|
||||
const payload = await readJsonMutation<{
|
||||
handoff_id?: string;
|
||||
phone_id?: string;
|
||||
phone_token_hash?: string;
|
||||
route_handle_hash?: string;
|
||||
}>(request);
|
||||
return Response.json(await completePhoneHandoffForRelay({
|
||||
principal,
|
||||
handoffId: payload.handoff_id ?? "",
|
||||
phoneId: payload.phone_id ?? "",
|
||||
phoneTokenHash: payload.phone_token_hash ?? "",
|
||||
routeHandleHash: payload.route_handle_hash ?? "",
|
||||
}), { headers: { "cache-control": "no-store" } });
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
authenticateRelayNode,
|
||||
consumePhoneHandoffForRelay,
|
||||
} from "@/db/relay-control-plane";
|
||||
import { apiError, readJsonMutation } from "../../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const principal = await authenticateRelayNode(request);
|
||||
const payload = await readJsonMutation<{
|
||||
ticket?: string;
|
||||
pwa_origin?: string;
|
||||
name?: string;
|
||||
phone_ed25519_public?: string;
|
||||
phone_x25519_public?: string;
|
||||
identity_fingerprint?: string;
|
||||
}>(request);
|
||||
return Response.json(await consumePhoneHandoffForRelay({
|
||||
principal,
|
||||
ticket: payload.ticket ?? "",
|
||||
pwaOrigin: payload.pwa_origin ?? "",
|
||||
name: payload.name ?? "",
|
||||
phoneEd25519Public: payload.phone_ed25519_public ?? "",
|
||||
phoneX25519Public: payload.phone_x25519_public ?? "",
|
||||
identityFingerprint: payload.identity_fingerprint ?? "",
|
||||
}), { headers: { "cache-control": "no-store" } });
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
authenticateRelayNode,
|
||||
heartbeatRelayNode,
|
||||
} from "@/db/relay-control-plane";
|
||||
import { relayMigrationAssignments } from "@/db/relay-migrations";
|
||||
import { relayPurgeAssignments } from "@/db/relay-purges";
|
||||
import { apiError, readJsonMutation } from "../../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const principal = await authenticateRelayNode(request);
|
||||
const payload = await readJsonMutation<{
|
||||
generation?: number;
|
||||
capacity_tenants?: number;
|
||||
}>(request);
|
||||
const heartbeat = await heartbeatRelayNode({
|
||||
principal,
|
||||
generation: Number(payload.generation ?? -1),
|
||||
capacityTenants: Number(payload.capacity_tenants ?? -1),
|
||||
});
|
||||
const [migrations, purges] = await Promise.all([
|
||||
relayMigrationAssignments(principal),
|
||||
relayPurgeAssignments(principal),
|
||||
]);
|
||||
return Response.json(
|
||||
{ ...heartbeat, migrations, purges },
|
||||
{ headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { authenticateRelayNode } from "@/db/relay-control-plane";
|
||||
import { advanceRelayMigration } from "@/db/relay-migrations";
|
||||
import { apiError, readJsonMutation } from "../../../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const principal = await authenticateRelayNode(request);
|
||||
const payload = await readJsonMutation<{
|
||||
migration_id?: string;
|
||||
action?: "quiesced" | "copied" | "switched" | "finalized" | "failed";
|
||||
backup_ref?: string;
|
||||
manifest_sha256?: string;
|
||||
error_code?: string;
|
||||
}>(request);
|
||||
if (!payload.action) {
|
||||
return Response.json(
|
||||
{ error_code: "invalid_relay_migration", message: "迁移动作无效", retryable: false },
|
||||
{ status: 400, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
}
|
||||
const migration = await advanceRelayMigration({
|
||||
principal,
|
||||
migrationId: payload.migration_id ?? "",
|
||||
action: payload.action,
|
||||
backupRef: payload.backup_ref,
|
||||
manifestSha256: payload.manifest_sha256,
|
||||
errorCode: payload.error_code,
|
||||
});
|
||||
return Response.json(
|
||||
{ migration_id: migration.id, state: migration.state, updated_at: migration.updated_at },
|
||||
{ headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { authenticateRelayNode } from "@/db/relay-control-plane";
|
||||
import { advanceRelayPurge } from "@/db/relay-purges";
|
||||
import { apiError, readJsonMutation } from "../../../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const principal = await authenticateRelayNode(request);
|
||||
const payload = await readJsonMutation<{
|
||||
purge_id?: string;
|
||||
action?: "completed" | "failed";
|
||||
evidence_sha256?: string;
|
||||
error_code?: string;
|
||||
}>(request);
|
||||
if (!payload.action) {
|
||||
return Response.json(
|
||||
{ error_code: "invalid_relay_purge", message: "租户删除动作无效", retryable: false },
|
||||
{ status: 400, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
}
|
||||
const purge = await advanceRelayPurge({
|
||||
principal,
|
||||
purgeId: payload.purge_id ?? "",
|
||||
action: payload.action,
|
||||
evidenceSha256: payload.evidence_sha256,
|
||||
errorCode: payload.error_code,
|
||||
});
|
||||
return Response.json(
|
||||
{ purge_id: purge.id, state: purge.state, updated_at: purge.updated_at },
|
||||
{ headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { authenticateRelayNode } from "@/db/relay-control-plane";
|
||||
import { claimDevice, DomainError } from "@/db/repository";
|
||||
import { apiError, readJsonMutation } from "../../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await authenticateRelayNode(request);
|
||||
const payload = await readJsonMutation<{
|
||||
bootstrap_token?: string;
|
||||
source_hash?: string;
|
||||
os?: string;
|
||||
ed25519_public?: string;
|
||||
x25519_public?: string;
|
||||
identity_fingerprint?: string;
|
||||
transport_mode?: string;
|
||||
registration_proof?: string;
|
||||
daemon_version?: string;
|
||||
registration_retry_key?: string;
|
||||
}>(request);
|
||||
const sourceHash = payload.source_hash?.trim().toLowerCase() ?? "";
|
||||
if (!/^[0-9a-f]{64}$/u.test(sourceHash)) {
|
||||
throw new DomainError("registration_rate_limited", "注册来源摘要无效", 429, true, 60);
|
||||
}
|
||||
const result = await claimDevice({
|
||||
bootstrapToken: payload.bootstrap_token ?? "",
|
||||
trustedSourceHash: sourceHash,
|
||||
os: payload.os ?? "",
|
||||
ed25519Public: payload.ed25519_public ?? "",
|
||||
x25519Public: payload.x25519_public ?? "",
|
||||
identityFingerprint: payload.identity_fingerprint ?? "",
|
||||
transportMode: payload.transport_mode ?? "",
|
||||
registrationProof: payload.registration_proof ?? "",
|
||||
daemonVersion: payload.daemon_version ?? "",
|
||||
registrationRetryKey: payload.registration_retry_key ?? "",
|
||||
});
|
||||
return Response.json(result, {
|
||||
status: 200,
|
||||
headers: { "cache-control": "no-store" },
|
||||
});
|
||||
} catch (error) {
|
||||
const response = apiError(error);
|
||||
response.headers.set("cache-control", "no-store");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
authenticateRelayNode,
|
||||
resolveDeviceRouteForRelay,
|
||||
} from "@/db/relay-control-plane";
|
||||
import { apiError, readJsonMutation } from "../../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const principal = await authenticateRelayNode(request);
|
||||
const payload = await readJsonMutation<{ device_id?: string; token_hash?: string }>(request);
|
||||
return Response.json(
|
||||
await resolveDeviceRouteForRelay({
|
||||
principal,
|
||||
deviceId: payload.device_id ?? "",
|
||||
tokenHash: payload.token_hash ?? "",
|
||||
}),
|
||||
{ headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
authenticateRelayNode,
|
||||
resolveHandoffRouteForRelay,
|
||||
} from "@/db/relay-control-plane";
|
||||
import { apiError, readJsonMutation } from "../../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const principal = await authenticateRelayNode(request);
|
||||
const payload = await readJsonMutation<{ ticket?: string; pwa_origin?: string }>(request);
|
||||
return Response.json(
|
||||
await resolveHandoffRouteForRelay({
|
||||
principal,
|
||||
ticket: payload.ticket ?? "",
|
||||
pwaOrigin: payload.pwa_origin ?? "",
|
||||
}),
|
||||
{ headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
authenticateRelayNode,
|
||||
resolvePhoneRouteForRelay,
|
||||
} from "@/db/relay-control-plane";
|
||||
import { apiError, readJsonMutation } from "../../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const principal = await authenticateRelayNode(request);
|
||||
const payload = await readJsonMutation<{
|
||||
route_handle?: string;
|
||||
phone_token_hash?: string;
|
||||
}>(request);
|
||||
return Response.json(
|
||||
await resolvePhoneRouteForRelay({
|
||||
principal,
|
||||
routeHandle: payload.route_handle ?? "",
|
||||
phoneTokenHash: payload.phone_token_hash ?? "",
|
||||
}),
|
||||
{ headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
authenticateRelayNode,
|
||||
resolveTenantRouteForRelay,
|
||||
} from "@/db/relay-control-plane";
|
||||
import { apiError, readJsonMutation } from "../../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const principal = await authenticateRelayNode(request);
|
||||
const payload = await readJsonMutation<{
|
||||
tenant_id?: string;
|
||||
placement_generation?: number;
|
||||
}>(request);
|
||||
return Response.json(
|
||||
await resolveTenantRouteForRelay({
|
||||
principal,
|
||||
tenantId: payload.tenant_id ?? "",
|
||||
placementGeneration: Number(payload.placement_generation ?? -1),
|
||||
}),
|
||||
{ headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
authenticateRelayNode,
|
||||
revokePhoneForRelay,
|
||||
} from "@/db/relay-control-plane";
|
||||
import { apiError, readJsonMutation } from "../../../respond";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const principal = await authenticateRelayNode(request);
|
||||
const payload = await readJsonMutation<{
|
||||
tenant_id?: string;
|
||||
phone_id?: string;
|
||||
reason?: string;
|
||||
}>(request);
|
||||
return Response.json(await revokePhoneForRelay({
|
||||
principal,
|
||||
tenantId: payload.tenant_id ?? "",
|
||||
phoneId: payload.phone_id ?? "",
|
||||
reason: payload.reason ?? "",
|
||||
}), { headers: { "cache-control": "no-store" } });
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { getCloudViewer } from "@/app/cloud-auth";
|
||||
import { createPhoneHandoffTicket } from "@/db/relay-control-plane";
|
||||
import { DomainError, getOrCreateAccount } from "@/db/repository";
|
||||
import { apiError } from "../../respond";
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const viewer = await getCloudViewer();
|
||||
if (!viewer) throw new DomainError("authentication_required", "请先登录", 401);
|
||||
const pwaOrigin = env.NEKONEST_CLOUD_PWA_ORIGIN?.trim() ?? "";
|
||||
if (!pwaOrigin) {
|
||||
throw new DomainError("pwa_handoff_unavailable", "Cloud PWA 地址尚未配置", 503, true, 30);
|
||||
}
|
||||
const account = await getOrCreateAccount(viewer);
|
||||
return Response.json(
|
||||
await createPhoneHandoffTicket({ accountId: account.id, pwaOrigin }),
|
||||
{ status: 201, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
} catch (error) {
|
||||
return apiError(error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { DomainError } from "@/db/repository";
|
||||
|
||||
function requireJsonMutation(request: Request): void {
|
||||
const contentType = request.headers
|
||||
.get("content-type")
|
||||
?.split(";", 1)[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (contentType !== "application/json") {
|
||||
throw new DomainError(
|
||||
"json_required",
|
||||
"写操作只接受 application/json",
|
||||
415,
|
||||
);
|
||||
}
|
||||
|
||||
const contentLength = Number(request.headers.get("content-length") ?? "0");
|
||||
if (Number.isFinite(contentLength) && contentLength > 32_768) {
|
||||
throw new DomainError("request_too_large", "请求内容过大", 413);
|
||||
}
|
||||
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin && origin !== new URL(request.url).origin) {
|
||||
throw new DomainError("invalid_origin", "拒绝跨站写操作", 403);
|
||||
}
|
||||
const fetchSite = request.headers.get("sec-fetch-site");
|
||||
if (fetchSite && fetchSite !== "same-origin") {
|
||||
throw new DomainError("invalid_fetch_site", "拒绝跨站写操作", 403);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJsonMutation<T>(request: Request): Promise<T> {
|
||||
requireJsonMutation(request);
|
||||
const body = await request.text();
|
||||
if (new TextEncoder().encode(body).byteLength > 32_768) {
|
||||
throw new DomainError("request_too_large", "请求内容过大", 413);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(body) as T;
|
||||
} catch {
|
||||
throw new DomainError("invalid_json", "请求不是有效 JSON", 400);
|
||||
}
|
||||
}
|
||||
|
||||
export function apiError(error: unknown) {
|
||||
if (error instanceof DomainError) {
|
||||
const headers = new Headers({ "cache-control": "no-store" });
|
||||
if (error.retryAfterSeconds !== undefined) {
|
||||
headers.set("retry-after", String(error.retryAfterSeconds));
|
||||
}
|
||||
return Response.json({
|
||||
error_code: error.code,
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
retryable: error.retryable,
|
||||
...(error.retryAfterSeconds === undefined
|
||||
? {}
|
||||
: { retry_after_seconds: error.retryAfterSeconds }),
|
||||
...(error.actionUrl === undefined ? {} : { action_url: error.actionUrl }),
|
||||
}, { status: error.status, headers });
|
||||
}
|
||||
console.error("Unhandled NekoNest Cloud API error", error);
|
||||
return Response.json(
|
||||
{
|
||||
error_code: "internal_error",
|
||||
error: "internal_error",
|
||||
message: "服务暂时不可用,请稍后重试",
|
||||
retryable: true,
|
||||
},
|
||||
{ status: 500, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { getServiceStatusSnapshot } from "@/db/repository";
|
||||
import { apiError } from "../respond";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const snapshot = await getServiceStatusSnapshot();
|
||||
return Response.json(snapshot, {
|
||||
headers: { "cache-control": "no-store" },
|
||||
});
|
||||
} catch (error) {
|
||||
const response = apiError(error);
|
||||
response.headers.set("cache-control", "no-store");
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export type ChatGPTUser = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
fullName: string | null;
|
||||
};
|
||||
|
||||
const USER_ID_HEADER = "oai-authenticated-user-id";
|
||||
const USER_EMAIL_HEADER = "oai-authenticated-user-email";
|
||||
const USER_FULL_NAME_HEADER = "oai-authenticated-user-full-name";
|
||||
const USER_FULL_NAME_ENCODING_HEADER =
|
||||
"oai-authenticated-user-full-name-encoding";
|
||||
const PERCENT_ENCODED_UTF8 = "percent-encoded-utf-8";
|
||||
const SIGN_IN_PATH = "/signin-with-chatgpt";
|
||||
const SIGN_OUT_PATH = "/signout-with-chatgpt";
|
||||
const CALLBACK_PATH = "/callback";
|
||||
|
||||
export async function getChatGPTUser(): Promise<ChatGPTUser | null> {
|
||||
const requestHeaders = await headers();
|
||||
const userId = requestHeaders.get(USER_ID_HEADER);
|
||||
const email = requestHeaders.get(USER_EMAIL_HEADER);
|
||||
if (!userId || !email) return null;
|
||||
|
||||
const encodedFullName = requestHeaders.get(USER_FULL_NAME_HEADER);
|
||||
const fullName =
|
||||
encodedFullName &&
|
||||
requestHeaders.get(USER_FULL_NAME_ENCODING_HEADER) === PERCENT_ENCODED_UTF8
|
||||
? safeDecodeURIComponent(encodedFullName)
|
||||
: null;
|
||||
|
||||
return {
|
||||
userId,
|
||||
displayName: fullName ?? email,
|
||||
email,
|
||||
fullName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireChatGPTUser(
|
||||
returnTo: string,
|
||||
): Promise<ChatGPTUser> {
|
||||
const user = await getChatGPTUser();
|
||||
if (user) return user;
|
||||
|
||||
redirect(chatGPTSignInPath(returnTo));
|
||||
}
|
||||
|
||||
export function chatGPTSignInPath(returnTo: string): string {
|
||||
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||
return `${SIGN_IN_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||
}
|
||||
|
||||
export function chatGPTSignOutPath(returnTo = "/"): string {
|
||||
const safeReturnTo = safeRelativeReturnPath(returnTo);
|
||||
return `${SIGN_OUT_PATH}?return_to=${encodeURIComponent(safeReturnTo)}`;
|
||||
}
|
||||
|
||||
function safeRelativeReturnPath(value: string): string {
|
||||
if (!value.startsWith("/") || value.startsWith("//")) return "/";
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value, "https://app.local");
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
if (url.origin !== "https://app.local") return "/";
|
||||
if (isReservedAuthPath(url.pathname)) return "/";
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
function isReservedAuthPath(pathname: string): boolean {
|
||||
return (
|
||||
pathname === SIGN_IN_PATH ||
|
||||
pathname === SIGN_OUT_PATH ||
|
||||
pathname === CALLBACK_PATH
|
||||
);
|
||||
}
|
||||
|
||||
function safeDecodeURIComponent(value: string): string | null {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { redirect } from "next/navigation";
|
||||
import {
|
||||
getChatGPTUser,
|
||||
requireChatGPTUser,
|
||||
type ChatGPTUser,
|
||||
} from "./chatgpt-auth";
|
||||
import type { CloudIdentity } from "@/db/repository";
|
||||
|
||||
export type CloudViewer = CloudIdentity & {
|
||||
isLocalDemo: boolean;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
function toIdentity(user: ChatGPTUser): CloudIdentity {
|
||||
return {
|
||||
userId: user.userId,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
};
|
||||
}
|
||||
|
||||
function localDemoIdentity(): CloudIdentity | null {
|
||||
if (process.env.NODE_ENV === "production") return null;
|
||||
return {
|
||||
userId: "local-demo-user",
|
||||
email: "demo@nekonest.local",
|
||||
displayName: "本地演示账户",
|
||||
};
|
||||
}
|
||||
|
||||
function isAdminEmail(email: string, isLocalDemo: boolean): boolean {
|
||||
if (isLocalDemo) return true;
|
||||
const configured = env.NEKONEST_CLOUD_ADMIN_EMAILS ?? "";
|
||||
const allowed = configured
|
||||
.split(",")
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
return allowed.includes(email.toLowerCase());
|
||||
}
|
||||
|
||||
export async function getCloudViewer(): Promise<CloudViewer | null> {
|
||||
const signedIn = await getChatGPTUser();
|
||||
const demo = signedIn ? null : localDemoIdentity();
|
||||
const identity = signedIn ? toIdentity(signedIn) : demo;
|
||||
if (!identity) return null;
|
||||
const isLocalDemo = Boolean(demo);
|
||||
return {
|
||||
...identity,
|
||||
isLocalDemo,
|
||||
isAdmin: isAdminEmail(identity.email, isLocalDemo),
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireCloudViewer(returnTo: string): Promise<CloudViewer> {
|
||||
const viewer = await getCloudViewer();
|
||||
if (viewer) return viewer;
|
||||
const user = await requireChatGPTUser(returnTo);
|
||||
const identity = toIdentity(user);
|
||||
return {
|
||||
...identity,
|
||||
isLocalDemo: false,
|
||||
isAdmin: isAdminEmail(identity.email, false),
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireAdminViewer(returnTo = "/admin"): Promise<CloudViewer> {
|
||||
const viewer = await requireCloudViewer(returnTo);
|
||||
if (!viewer.isAdmin) redirect("/dashboard?admin=denied");
|
||||
return viewer;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
function subscribeToConnectivity(onStoreChange: () => void) {
|
||||
const handleOnline = () => onStoreChange();
|
||||
const handleOffline = () => onStoreChange();
|
||||
|
||||
window.addEventListener("online", handleOnline);
|
||||
window.addEventListener("offline", handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("online", handleOnline);
|
||||
window.removeEventListener("offline", handleOffline);
|
||||
};
|
||||
}
|
||||
|
||||
function getOfflineSnapshot() {
|
||||
return !navigator.onLine;
|
||||
}
|
||||
|
||||
function getServerOfflineSnapshot() {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function ConnectivityBanner() {
|
||||
const offline = useSyncExternalStore(
|
||||
subscribeToConnectivity,
|
||||
getOfflineSnapshot,
|
||||
getServerOfflineSnapshot,
|
||||
);
|
||||
|
||||
if (!offline) return null;
|
||||
|
||||
return (
|
||||
<div className="connectivity-banner" role="status" aria-live="polite">
|
||||
<strong>当前设备离线</strong>
|
||||
<span>
|
||||
页面内容可能已经过期。恢复网络后请重新加载;已提交的操作先核对状态,避免重复。
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
export function RouteLoadingState({
|
||||
area,
|
||||
description,
|
||||
}: {
|
||||
area: string;
|
||||
description: string;
|
||||
}) {
|
||||
return (
|
||||
<main className="route-state-shell" aria-busy="true" aria-live="polite">
|
||||
<section className="route-state-card route-loading-card">
|
||||
<span className="route-state-kicker">{area}</span>
|
||||
<h1>正在加载最新状态</h1>
|
||||
<p>{description}</p>
|
||||
<div className="route-loading-lines" aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function RouteErrorState({
|
||||
area,
|
||||
title,
|
||||
description,
|
||||
reset,
|
||||
dashboard = false,
|
||||
}: {
|
||||
area: string;
|
||||
title: string;
|
||||
description: string;
|
||||
reset: () => void;
|
||||
dashboard?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<main className="route-state-shell">
|
||||
<section className="route-state-card route-error-card" role="alert">
|
||||
<span className="route-state-kicker">{area}</span>
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
<p className="route-state-note">
|
||||
这次页面失败不能证明你的主机或会话中继已经离线。先查看服务状态,再决定是否重新执行刚才的操作。
|
||||
</p>
|
||||
<div className="route-state-actions">
|
||||
<button className="button button-primary" type="button" onClick={reset}>
|
||||
重试加载
|
||||
</button>
|
||||
<Link className="button button-secondary" href="/status">
|
||||
查看服务状态
|
||||
</Link>
|
||||
<Link
|
||||
className="route-state-link"
|
||||
href={dashboard ? "/dashboard/feedback" : "/"}
|
||||
>
|
||||
{dashboard ? "仍有问题,提交反馈" : "返回首页"}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { chatGPTSignInPath, chatGPTSignOutPath } from "../chatgpt-auth";
|
||||
import { getCloudViewer, type CloudViewer } from "../cloud-auth";
|
||||
import {
|
||||
getActiveBeta,
|
||||
getServiceStatusSnapshot,
|
||||
type ServiceStatusSnapshot,
|
||||
} from "@/db/repository";
|
||||
import { ConnectivityBanner } from "./ConnectivityBanner";
|
||||
|
||||
const serviceStatusLabels = {
|
||||
operational: "服务正常",
|
||||
maintenance: "计划维护",
|
||||
degraded: "服务降级",
|
||||
outage: "服务中断",
|
||||
} as const;
|
||||
|
||||
export function Brand({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<span className="brand-lockup">
|
||||
<span className="brand-mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 40 40" role="img">
|
||||
<path d="M8 16 5 6l11 6h8L35 6l-3 10v10c0 6-5 10-12 10S8 32 8 26V16Z" />
|
||||
<path d="M14 23h.1M26 23h.1M16 29c2.4 1.8 5.6 1.8 8 0" />
|
||||
</svg>
|
||||
</span>
|
||||
{!compact && (
|
||||
<span className="brand-type">
|
||||
<strong>NekoNest</strong>
|
||||
<span>Cloud</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusPill({
|
||||
tone = "neutral",
|
||||
children,
|
||||
}: {
|
||||
tone?: "good" | "warn" | "danger" | "neutral" | "info";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <span className={`status-pill status-${tone}`}>{children}</span>;
|
||||
}
|
||||
|
||||
export async function PublicShell({
|
||||
children,
|
||||
serviceStatus: providedServiceStatus,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
serviceStatus?: ServiceStatusSnapshot;
|
||||
}) {
|
||||
const [viewer, beta, serviceStatus] = await Promise.all([
|
||||
getCloudViewer(),
|
||||
getActiveBeta(),
|
||||
providedServiceStatus ?? getServiceStatusSnapshot(),
|
||||
]);
|
||||
return (
|
||||
<div className="site-shell">
|
||||
<header className="public-header">
|
||||
<div className="public-header-inner">
|
||||
<Link className="brand-link" href="/" aria-label="NekoNest Cloud 首页">
|
||||
<Brand />
|
||||
</Link>
|
||||
<nav className="public-nav" aria-label="主要导航">
|
||||
<Link href="/#how">工作原理</Link>
|
||||
<Link href="/download">下载</Link>
|
||||
<Link href="/pricing">公测</Link>
|
||||
<Link href="/trust">信任边界</Link>
|
||||
<Link href="/privacy">数据说明</Link>
|
||||
<Link href="/readiness">上线门禁</Link>
|
||||
<Link href="/status">服务状态</Link>
|
||||
</nav>
|
||||
<div className="header-actions">
|
||||
<span className="beta-chip">公开原型 · {beta ? "公测免费" : "公测已结束"}</span>
|
||||
{viewer ? (
|
||||
<Link className="button button-small button-primary" href="/dashboard">
|
||||
打开控制台
|
||||
</Link>
|
||||
) : (
|
||||
<Link
|
||||
className="button button-small button-primary"
|
||||
href={chatGPTSignInPath("/dashboard")}
|
||||
>
|
||||
登录控制台
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<ConnectivityBanner />
|
||||
<main>{children}</main>
|
||||
<footer className="public-footer">
|
||||
<div className="footer-grid">
|
||||
<div>
|
||||
<Brand />
|
||||
<p>把电脑上的 coding-agent,接回手机继续。</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>产品</strong>
|
||||
<Link href="/download">下载主机端</Link>
|
||||
<Link href="/pricing">免费公测</Link>
|
||||
<Link href="/trust">安全与隐私边界</Link>
|
||||
<Link href="/privacy">公测数据说明</Link>
|
||||
<Link href="/readiness">上线准备度</Link>
|
||||
<Link href="/status">服务状态</Link>
|
||||
</div>
|
||||
<div>
|
||||
<strong>边界</strong>
|
||||
<p>自托管永久免费;Cloud 不运行模型,不卖 Token,不设积分。</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>经营信息</strong>
|
||||
<p>主体与备案路径仍待确认;公测免费,收费功能暂不开放。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="footer-bottom">
|
||||
<span>© 2026 NekoNest Cloud prototype</span>
|
||||
<span>{serviceStatusLabels[serviceStatus.status]} · 页面内容不是正式收费或合规承诺</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dashboardNav = [
|
||||
{ href: "/dashboard", label: "总览", icon: "⌂" },
|
||||
{ href: "/dashboard/hosts", label: "主机", icon: "▣" },
|
||||
{ href: "/dashboard/billing", label: "公测权益", icon: "○" },
|
||||
{ href: "/dashboard/security", label: "安全与设备", icon: "◇" },
|
||||
{ href: "/dashboard/feedback", label: "问题反馈", icon: "?" },
|
||||
] as const;
|
||||
|
||||
export async function DashboardShell({
|
||||
viewer,
|
||||
active,
|
||||
children,
|
||||
serviceStatus: providedServiceStatus,
|
||||
}: {
|
||||
viewer: CloudViewer;
|
||||
active: string;
|
||||
children: ReactNode;
|
||||
serviceStatus?: ServiceStatusSnapshot;
|
||||
}) {
|
||||
const serviceStatus =
|
||||
providedServiceStatus ?? (await getServiceStatusSnapshot());
|
||||
return (
|
||||
<div className="cloud-shell">
|
||||
<aside className="cloud-sidebar">
|
||||
<Link className="brand-link cloud-brand" href="/">
|
||||
<Brand />
|
||||
</Link>
|
||||
<div className="prototype-notice">
|
||||
<span className="notice-dot" />
|
||||
控制平面原型
|
||||
</div>
|
||||
<nav className="cloud-nav" aria-label="控制台导航">
|
||||
{dashboardNav.map((item) => (
|
||||
<Link
|
||||
className={active === item.href ? "active" : undefined}
|
||||
href={item.href}
|
||||
key={item.href}
|
||||
>
|
||||
<span aria-hidden="true">{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
{viewer.isAdmin && (
|
||||
<Link className={active === "/admin" ? "active" : undefined} href="/admin">
|
||||
<span aria-hidden="true">⚙</span>
|
||||
公测后台
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
<div className="sidebar-account">
|
||||
<span className="account-avatar" aria-hidden="true">
|
||||
{viewer.displayName.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{viewer.displayName}</strong>
|
||||
<small>{viewer.isLocalDemo ? "本地演示身份" : viewer.email}</small>
|
||||
</span>
|
||||
{!viewer.isLocalDemo && (
|
||||
<Link href={chatGPTSignOutPath("/")} aria-label="退出登录">
|
||||
↗
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="cloud-main">
|
||||
<ConnectivityBanner />
|
||||
{serviceStatus.activeIncidents.length > 0 && (
|
||||
<div
|
||||
className={`service-incident-banner incident-${serviceStatus.status}`}
|
||||
role="status"
|
||||
>
|
||||
<span>
|
||||
<strong>{serviceStatusLabels[serviceStatus.status]}</strong>
|
||||
{serviceStatus.activeIncidents[0].title}
|
||||
</span>
|
||||
<Link href="/status">查看详情 →</Link>
|
||||
</div>
|
||||
)}
|
||||
{viewer.isLocalDemo && (
|
||||
<div className="demo-banner" role="status">
|
||||
当前使用本地演示身份;正式托管环境会要求登录,演示数据不会代表真实在线服务。
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeading({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="page-heading">
|
||||
<div>
|
||||
{eyebrow && <span className="eyebrow">{eyebrow}</span>}
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
{actions && <div className="page-actions">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatMoney(amountMinor: number, currency = "CNY") {
|
||||
return new Intl.NumberFormat("zh-CN", {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amountMinor / 100);
|
||||
}
|
||||
|
||||
export function formatDate(value: string | null, includeTime = false) {
|
||||
if (!value) return "未设定";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(includeTime ? { hour: "2-digit", minute: "2-digit" } : {}),
|
||||
}).format(new Date(value));
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { DAEMON_RELEASE_POLICY } from "../release/daemon-policy.mjs";
|
||||
import {
|
||||
compareStableVersions,
|
||||
MINIMUM_CLOUD_DAEMON_VERSION,
|
||||
parseStableVersion,
|
||||
} from "../release/daemon-version.ts";
|
||||
|
||||
export { MINIMUM_CLOUD_DAEMON_VERSION };
|
||||
|
||||
type ReleaseEnvironment = Partial<{
|
||||
NEKONEST_CLOUD_DAEMON_RELEASE_VERSION: string;
|
||||
NEKONEST_CLOUD_DAEMON_RELEASE_BASE_URL: string;
|
||||
NEKONEST_CLOUD_DAEMON_WINDOWS_AMD64_SHA256: string;
|
||||
NEKONEST_CLOUD_DAEMON_LINUX_AMD64_SHA256: string;
|
||||
NEKONEST_CLOUD_DAEMON_LINUX_ARM64_SHA256: string;
|
||||
}>;
|
||||
|
||||
export type DaemonReleaseAsset = {
|
||||
platform: "windows" | "linux";
|
||||
architecture: "amd64" | "arm64";
|
||||
label: string;
|
||||
filename: string;
|
||||
downloadUrl: string;
|
||||
sha256: string;
|
||||
};
|
||||
|
||||
export type DaemonReleaseState =
|
||||
| {
|
||||
available: false;
|
||||
reason: "not_configured" | "invalid_config" | "incompatible_version";
|
||||
minimumVersion: string;
|
||||
}
|
||||
| {
|
||||
available: true;
|
||||
version: string;
|
||||
minimumVersion: string;
|
||||
releasePageUrl: string;
|
||||
checksumsUrl: string;
|
||||
assets: DaemonReleaseAsset[];
|
||||
};
|
||||
|
||||
const ASSETS = DAEMON_RELEASE_POLICY.assets;
|
||||
|
||||
function safeReleaseBaseUrl(raw: string | undefined, version: string): string {
|
||||
const fallback = `https://github.com/${DAEMON_RELEASE_POLICY.repository}/releases/download/v${version}`;
|
||||
const parsed = new URL(raw?.trim() || fallback);
|
||||
if (
|
||||
parsed.protocol !== "https:" ||
|
||||
parsed.username ||
|
||||
parsed.password ||
|
||||
parsed.search ||
|
||||
parsed.hash
|
||||
) {
|
||||
throw new Error("unsafe_release_base_url");
|
||||
}
|
||||
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function normalizedChecksum(value: string | undefined): string {
|
||||
const checksum = value?.trim().toLowerCase() ?? "";
|
||||
if (!/^[0-9a-f]{64}$/.test(checksum)) throw new Error("invalid_release_checksum");
|
||||
return checksum;
|
||||
}
|
||||
|
||||
export function parseDaemonReleaseEnvironment(config: ReleaseEnvironment): DaemonReleaseState {
|
||||
const version = config.NEKONEST_CLOUD_DAEMON_RELEASE_VERSION?.trim() ?? "";
|
||||
if (!version) {
|
||||
return {
|
||||
available: false,
|
||||
reason: "not_configured",
|
||||
minimumVersion: MINIMUM_CLOUD_DAEMON_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
const parsedVersion = parseStableVersion(version);
|
||||
const minimumVersion = parseStableVersion(MINIMUM_CLOUD_DAEMON_VERSION);
|
||||
if (!parsedVersion || !minimumVersion) {
|
||||
return {
|
||||
available: false,
|
||||
reason: "invalid_config",
|
||||
minimumVersion: MINIMUM_CLOUD_DAEMON_VERSION,
|
||||
};
|
||||
}
|
||||
if (compareStableVersions(parsedVersion, minimumVersion) < 0) {
|
||||
return {
|
||||
available: false,
|
||||
reason: "incompatible_version",
|
||||
minimumVersion: MINIMUM_CLOUD_DAEMON_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = safeReleaseBaseUrl(
|
||||
config.NEKONEST_CLOUD_DAEMON_RELEASE_BASE_URL,
|
||||
version,
|
||||
);
|
||||
const assets = ASSETS.map((asset) => ({
|
||||
platform: asset.platform as DaemonReleaseAsset["platform"],
|
||||
architecture: asset.architecture as DaemonReleaseAsset["architecture"],
|
||||
label: asset.label,
|
||||
filename: asset.filename,
|
||||
downloadUrl: `${baseUrl}/${asset.filename}`,
|
||||
sha256: normalizedChecksum(config[asset.checksumEnv as keyof ReleaseEnvironment]),
|
||||
}));
|
||||
return {
|
||||
available: true,
|
||||
version,
|
||||
minimumVersion: MINIMUM_CLOUD_DAEMON_VERSION,
|
||||
releasePageUrl: `https://github.com/${DAEMON_RELEASE_POLICY.repository}/releases/tag/v${version}`,
|
||||
checksumsUrl: `${baseUrl}/checksums.txt`,
|
||||
assets,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
available: false,
|
||||
reason: "invalid_config",
|
||||
minimumVersion: MINIMUM_CLOUD_DAEMON_VERSION,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDaemonReleaseState(): Promise<DaemonReleaseState> {
|
||||
const { env } = await import("cloudflare:workers");
|
||||
return parseDaemonReleaseEnvironment(env);
|
||||
}
|
||||
|
||||
export function checksumVerificationCommand(asset: DaemonReleaseAsset): string {
|
||||
if (asset.platform === "windows") {
|
||||
return [
|
||||
`$expected = '${asset.sha256}'`,
|
||||
`$actual = (Get-FileHash -LiteralPath '.\\${asset.filename}' -Algorithm SHA256).Hash.ToLowerInvariant()`,
|
||||
`if ($actual -ne $expected) { throw 'SHA-256 不匹配,请删除文件' }`,
|
||||
`Write-Host 'SHA-256 校验通过'`,
|
||||
].join("\n");
|
||||
}
|
||||
return `printf '%s %s\\n' '${asset.sha256}' '${asset.filename}' | sha256sum -c -`;
|
||||
}
|
||||
@@ -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>首批申请限 1–3 台,实际名额以审核结果为准。</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>一次性配对码已经从页面状态中清除。控制平面已建立可撤销设备凭据并推进授权 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { RouteLoadingState } from "../components/RouteStates";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<RouteLoadingState
|
||||
area="控制台"
|
||||
description="正在核对账户、公测权益、主机和服务状态,请稍候。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} 台主机。`,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PublicShell, StatusPill } from "../components/Shells";
|
||||
import {
|
||||
checksumVerificationCommand,
|
||||
getDaemonReleaseState,
|
||||
} from "../daemon-release";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "下载主机端 daemon",
|
||||
description: "下载并核验用于 NekoNest Cloud 免费公测的 Windows 或 Linux 主机端 daemon。",
|
||||
};
|
||||
|
||||
const unavailableCopy = {
|
||||
not_configured: {
|
||||
title: "兼容 Cloud 的公开构建尚未上架。",
|
||||
body: "下载清单还没有同时配置兼容版本、三个平台包和各自 SHA-256,因此入口保持关闭。闭测参与者可以继续使用单独提供并完成核验的构建。",
|
||||
},
|
||||
invalid_config: {
|
||||
title: "下载清单未通过安全校验。",
|
||||
body: "版本、HTTPS 下载地址或 SHA-256 有一项不完整。修复前不会把用户送到不确定的二进制文件。",
|
||||
},
|
||||
incompatible_version: {
|
||||
title: "现有发布版本不兼容 Cloud 接入。",
|
||||
body: "下载入口只接受实现控制面激活交接的 daemon。旧版仍可用于自托管,但不会作为 Cloud 客户端展示。",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default async function DownloadPage() {
|
||||
const release = await getDaemonReleaseState();
|
||||
return (
|
||||
<PublicShell>
|
||||
<div className="public-page download-page">
|
||||
<header className="download-hero">
|
||||
<StatusPill tone={release.available ? "good" : "warn"}>
|
||||
{release.available ? `CLOUD DAEMON v${release.version}` : "DOWNLOAD GATED"}
|
||||
</StatusPill>
|
||||
<h1>{release.available ? "下载、核验,再连接。" : unavailableCopy[release.reason].title}</h1>
|
||||
<p>
|
||||
{release.available
|
||||
? "选择主机平台,下载固定版本压缩包,并在解压和运行前核对 SHA-256。主机无需开放入站端口。"
|
||||
: unavailableCopy[release.reason].body}
|
||||
</p>
|
||||
<small>Cloud 最低兼容版本:v{release.minimumVersion} · macOS 暂未正式支持</small>
|
||||
</header>
|
||||
|
||||
{release.available ? (
|
||||
<>
|
||||
<section className="download-section" aria-labelledby="download-platform-title">
|
||||
<div className="download-heading">
|
||||
<span className="eyebrow">CHOOSE PLATFORM / 选择平台</span>
|
||||
<h2 id="download-platform-title">三个包,三个独立摘要。</h2>
|
||||
<p>Linux 可运行 <code>uname -m</code> 确认架构;Windows 当前只提供 x64。</p>
|
||||
</div>
|
||||
<div className="download-grid">
|
||||
{release.assets.map((asset) => (
|
||||
<article className="download-card" key={`${asset.platform}-${asset.architecture}`}>
|
||||
<span className="download-platform">{asset.platform === "windows" ? "WINDOWS" : "LINUX"}</span>
|
||||
<h3>{asset.label}</h3>
|
||||
<code className="download-filename">{asset.filename}</code>
|
||||
<a className="button button-primary" href={asset.downloadUrl} rel="noopener noreferrer">下载 v{release.version}</a>
|
||||
<div className="checksum-block">
|
||||
<strong>发布摘要</strong>
|
||||
<code>{asset.sha256}</code>
|
||||
</div>
|
||||
<details>
|
||||
<summary>查看校验命令</summary>
|
||||
<pre><code>{checksumVerificationCommand(asset)}</code></pre>
|
||||
</details>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="download-proof">
|
||||
<div>
|
||||
<span className="eyebrow">VERIFY THE RELEASE / 核对发布</span>
|
||||
<h2>摘要校验不是代码签名。</h2>
|
||||
<p>SHA-256 只能证明下载文件与控制台公布的字节一致。当前发布流水线尚未提供 Windows Authenticode 或其他发布者代码签名,因此这项能力仍是扩大公测前的门禁。</p>
|
||||
</div>
|
||||
<div className="download-proof-actions">
|
||||
<a className="button button-secondary" href={release.checksumsUrl} rel="noopener noreferrer">下载 checksums.txt</a>
|
||||
<a className="button button-secondary" href={release.releasePageUrl} rel="noopener noreferrer">查看 v{release.version} 发布记录</a>
|
||||
<Link className="button button-primary" href="/dashboard/hosts/new">已下载,开始配对</Link>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<section className="download-gated" aria-labelledby="download-gated-title">
|
||||
<div>
|
||||
<span className="eyebrow">FAIL CLOSED / 暂停分发</span>
|
||||
<h2 id="download-gated-title">不提供“先下最新版试试”的按钮。</h2>
|
||||
<p>Release 的“最新版”可能仍只适用于自托管。Cloud 必须同时确认最低兼容版本、固定 HTTPS 地址和每个平台的 SHA-256 后,才会显示直接下载入口。</p>
|
||||
</div>
|
||||
<div className="download-gated-actions">
|
||||
<a className="button button-secondary" href="https://github.com/klarkxy/nekonest/releases" rel="noopener noreferrer">仅查看上游发布记录</a>
|
||||
<Link className="button button-primary" href="/readiness">查看公测门禁</Link>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</PublicShell>
|
||||
);
|
||||
}
|
||||
+904
@@ -0,0 +1,904 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--ink: #141727;
|
||||
--ink-2: #20243a;
|
||||
--paper: #f3f3ec;
|
||||
--paper-2: #e7e7dc;
|
||||
--white: #fffefa;
|
||||
--muted: #686b78;
|
||||
--line: #d2d1c4;
|
||||
--lime: #caff69;
|
||||
--lime-deep: #7cbb22;
|
||||
--coral: #ff7968;
|
||||
--blue: #6e8cff;
|
||||
--cyan: #60d5c9;
|
||||
--danger: #c33c48;
|
||||
--shadow: 0 22px 70px rgb(13 16 29 / 14%);
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 18px;
|
||||
--radius-lg: 28px;
|
||||
--max: 1240px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
|
||||
outline: 3px solid var(--blue);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.site-shell { min-height: 100vh; overflow: clip; }
|
||||
.public-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
color: white;
|
||||
background: rgb(18 21 34 / 92%);
|
||||
border-bottom: 1px solid rgb(255 255 255 / 10%);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
.public-header-inner {
|
||||
width: min(var(--max), calc(100% - 40px));
|
||||
min-height: 72px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 34px;
|
||||
}
|
||||
.brand-link { display: inline-flex; flex: none; }
|
||||
.brand-lockup { display: inline-flex; align-items: center; gap: 11px; }
|
||||
.brand-mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--ink);
|
||||
background: var(--lime);
|
||||
border-radius: 12px 12px 14px 14px;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
.brand-mark svg { width: 32px; height: 32px; fill: currentColor; }
|
||||
.brand-mark svg path:last-child { fill: none; stroke: var(--lime); stroke-width: 2.2; stroke-linecap: round; }
|
||||
.brand-type { display: flex; align-items: baseline; gap: 6px; letter-spacing: -.02em; }
|
||||
.brand-type strong { font-size: 18px; }
|
||||
.brand-type span { font-size: 13px; color: #aeb1bf; text-transform: uppercase; letter-spacing: .12em; }
|
||||
.public-nav { display: flex; align-items: center; gap: 26px; margin-left: auto; }
|
||||
.public-nav a { color: #c6c8d0; font-size: 14px; }
|
||||
.public-nav a:hover { color: white; }
|
||||
.header-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.beta-chip { color: var(--lime); font-size: 12px; border-left: 1px solid #44485d; padding-left: 16px; }
|
||||
|
||||
.button {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
padding: 0 22px;
|
||||
font-weight: 720;
|
||||
cursor: pointer;
|
||||
transition: transform .18s ease, box-shadow .18s ease, background-color .18s ease;
|
||||
}
|
||||
.button:hover { transform: translateY(-2px); }
|
||||
.button-small { min-height: 40px; padding: 0 18px; font-size: 14px; }
|
||||
.button-large { min-height: 54px; padding: 0 27px; }
|
||||
.button-primary { color: var(--ink); background: var(--lime); box-shadow: 0 10px 30px rgb(202 255 105 / 16%); }
|
||||
.button-primary:hover { background: #d8ff8f; box-shadow: 0 14px 36px rgb(202 255 105 / 25%); }
|
||||
.button-ghost { color: white; border-color: rgb(255 255 255 / 26%); background: transparent; }
|
||||
.button-ghost:hover { border-color: white; }
|
||||
.button-light { color: var(--ink); background: var(--white); }
|
||||
.button-secondary { color: var(--ink); border-color: var(--line); background: var(--white); }
|
||||
.button-danger { color: white; background: var(--danger); }
|
||||
.button[disabled] { opacity: .48; cursor: not-allowed; transform: none; box-shadow: none; }
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: fit-content;
|
||||
min-height: 26px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.status-good { color: #2e6317; background: #dff9b8; }
|
||||
.status-warn { color: #7b4a00; background: #ffe3a6; }
|
||||
.status-danger { color: #8d2834; background: #ffd1d4; }
|
||||
.status-info { color: #304fab; background: #dbe3ff; }
|
||||
.status-neutral { color: #555867; background: #e8e8e1; }
|
||||
|
||||
.hero-section {
|
||||
position: relative;
|
||||
color: white;
|
||||
background:
|
||||
radial-gradient(circle at 82% 30%, rgb(110 140 255 / 16%), transparent 32%),
|
||||
radial-gradient(circle at 4% 90%, rgb(96 213 201 / 10%), transparent 35%),
|
||||
var(--ink);
|
||||
padding: 86px 0 96px;
|
||||
}
|
||||
.hero-section::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
opacity: .2;
|
||||
background-image: linear-gradient(rgb(255 255 255 / 5%) 1px, transparent 1px), linear-gradient(90deg, rgb(255 255 255 / 5%) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
mask-image: linear-gradient(to bottom, black, transparent 82%);
|
||||
}
|
||||
.hero-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(var(--max), calc(100% - 40px));
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: .86fr 1.14fr;
|
||||
align-items: center;
|
||||
gap: 56px;
|
||||
}
|
||||
.hero-kicker { display: flex; align-items: center; gap: 12px; color: #acafbd; font-size: 13px; margin-bottom: 28px; }
|
||||
.hero-copy h1 {
|
||||
margin: 0;
|
||||
max-width: 700px;
|
||||
font-size: clamp(54px, 6.2vw, 92px);
|
||||
font-weight: 780;
|
||||
line-height: .98;
|
||||
letter-spacing: -.065em;
|
||||
}
|
||||
.hero-copy h1 em { color: var(--lime); font-style: normal; font-family: ui-monospace, "SFMono-Regular", Consolas, monospace; font-size: .8em; letter-spacing: -.055em; }
|
||||
.hero-lead { max-width: 620px; margin: 30px 0 0; color: #c1c4d0; font-size: 18px; line-height: 1.75; }
|
||||
.hero-actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 36px; }
|
||||
.hero-facts { display: flex; flex-wrap: wrap; gap: 28px; margin-top: 44px; color: #999dac; font-size: 12px; }
|
||||
.hero-facts strong { color: white; font-size: 18px; margin-right: 5px; }
|
||||
|
||||
.hero-console { min-width: 0; border: 1px solid #3c4157; border-radius: 22px; background: #0c0f19; box-shadow: 0 38px 100px rgb(0 0 0 / 42%); overflow: hidden; transform: perspective(1600px) rotateY(-2deg) rotateX(1deg); }
|
||||
.console-topbar { min-height: 54px; display: flex; align-items: center; gap: 14px; padding: 0 18px; border-bottom: 1px solid #2a2e40; color: #b9bdca; font-size: 12px; }
|
||||
.console-topbar > span:nth-child(2) { flex: 1; }
|
||||
.console-dots { display: flex; gap: 6px; }
|
||||
.console-dots i { width: 8px; height: 8px; border-radius: 50%; background: #454a60; }
|
||||
.console-dots i:first-child { background: var(--coral); }
|
||||
.console-dots i:nth-child(2) { background: #f6c75d; }
|
||||
.console-dots i:nth-child(3) { background: var(--cyan); }
|
||||
.console-layout { min-height: 475px; display: grid; grid-template-columns: 205px 1fr; }
|
||||
.console-tree { padding: 20px 14px; border-right: 1px solid #292d3f; background: #111522; color: #a8adbd; font-size: 11px; }
|
||||
.console-tree > strong { display: block; padding: 8px 8px 16px; color: white; font-family: ui-monospace, Consolas, monospace; font-size: 10px; overflow-wrap: anywhere; }
|
||||
.tree-label { display: block; padding: 0 8px; color: #6f7487; text-transform: uppercase; letter-spacing: .12em; }
|
||||
.tree-agent, .tree-thread { min-height: 32px; display: flex; align-items: center; gap: 7px; padding: 7px 8px; border-radius: 7px; margin-bottom: 3px; }
|
||||
.tree-agent small { margin-left: auto; color: #6f7487; font-size: 9px; }
|
||||
.tree-agent.active { color: white; background: #20263a; }
|
||||
.tree-thread { padding-left: 28px; color: #7f8497; }
|
||||
.tree-thread.active { color: var(--lime); background: rgb(202 255 105 / 8%); }
|
||||
.console-chat { min-width: 0; display: flex; flex-direction: column; padding: 18px; }
|
||||
.chat-meta { display: flex; align-items: center; justify-content: space-between; color: #757a8d; font-size: 10px; padding-bottom: 20px; }
|
||||
.message { display: flex; gap: 10px; margin-bottom: 14px; }
|
||||
.message p { width: fit-content; max-width: 88%; margin: 0; padding: 13px 15px; border-radius: 8px 16px 16px 16px; color: #d9dce5; background: #1a1f30; font-size: 12px; line-height: 1.7; }
|
||||
.message.user { justify-content: flex-end; }
|
||||
.message.user p { color: var(--ink); background: var(--lime); border-radius: 16px 8px 16px 16px; }
|
||||
.message-avatar { flex: none; width: 26px; height: 26px; display: grid; place-items: center; border-radius: 8px; background: #6e8cff; color: white; font-weight: 800; font-size: 11px; }
|
||||
.delivery-row { margin: auto 0 12px; padding: 10px 12px; display: flex; align-items: center; gap: 9px; border: 1px solid #282d3f; border-radius: 8px; color: #8f94a4; font-size: 9px; }
|
||||
.pulse-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--cyan); box-shadow: 0 0 0 4px rgb(96 213 201 / 10%); }
|
||||
.composer-mock { min-height: 52px; display: flex; align-items: center; gap: 12px; border: 1px solid #363b50; border-radius: 12px; padding: 7px 7px 7px 14px; color: #6f7486; font-size: 11px; }
|
||||
.composer-mock span { flex: 1; }
|
||||
.composer-mock button { width: 36px; height: 36px; border: 0; border-radius: 9px; color: var(--ink); background: var(--lime); }
|
||||
.console-caption { padding: 10px 16px; border-top: 1px solid #24283a; color: #696e80; background: #0a0d16; font-size: 9px; }
|
||||
|
||||
.proof-strip { display: flex; justify-content: center; flex-wrap: wrap; gap: 0; color: #404454; background: var(--lime); border-bottom: 1px solid #acd94f; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.proof-strip span { padding: 18px 28px; border-left: 1px solid rgb(20 23 39 / 16%); }
|
||||
.proof-strip span:last-child { border-right: 1px solid rgb(20 23 39 / 16%); }
|
||||
|
||||
.section { padding: 104px 0; }
|
||||
.section-heading, .relay-diagram, .steps-grid, .feature-ledger, .price-callout, .trust-grid { width: min(var(--max), calc(100% - 40px)); margin-left: auto; margin-right: auto; }
|
||||
.split-heading { display: grid; grid-template-columns: 1fr .72fr; gap: 70px; align-items: end; margin-bottom: 64px; }
|
||||
.section-heading h2, .price-copy h2, .trust-title h2, .readiness-banner h2 { margin: 8px 0 0; font-size: clamp(40px, 5vw, 68px); line-height: 1.02; letter-spacing: -.055em; }
|
||||
.section-heading p, .price-copy > p, .trust-title > p { margin: 0; color: var(--muted); font-size: 17px; line-height: 1.75; }
|
||||
.eyebrow { display: block; color: var(--lime-deep); font-family: ui-monospace, Consolas, monospace; font-size: 12px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
|
||||
|
||||
.light-section { background: var(--paper); }
|
||||
.relay-diagram { display: grid; grid-template-columns: 1fr 165px 1.15fr 165px 1fr; align-items: center; }
|
||||
.relay-node { min-height: 210px; display: flex; flex-direction: column; justify-content: flex-end; position: relative; padding: 26px; border: 1px solid var(--line); background: var(--white); }
|
||||
.relay-node:first-child { border-radius: 28px 8px 8px 28px; }
|
||||
.relay-node:last-child { border-radius: 8px 28px 28px 8px; }
|
||||
.relay-node strong { font-size: 23px; }
|
||||
.relay-node small { margin-top: 8px; color: var(--muted); line-height: 1.5; }
|
||||
.relay-node b { position: absolute; top: 24px; right: 24px; color: var(--danger); font-size: 11px; border: 1px solid #edb4b8; padding: 5px 8px; border-radius: 999px; }
|
||||
.node-number { position: absolute; top: 22px; left: 24px; color: #b2b2a8; font-family: ui-monospace, Consolas, monospace; font-size: 12px; }
|
||||
.cloud-node { color: white; background: var(--ink); border-color: var(--ink); transform: scale(1.035); z-index: 1; border-radius: 16px; box-shadow: var(--shadow); }
|
||||
.cloud-node small { color: #aeb2c1; }
|
||||
.relay-arrow { display: flex; flex-direction: column; align-items: center; gap: 9px; color: #737682; font-size: 10px; text-align: center; }
|
||||
.relay-arrow i { width: 100%; height: 1px; position: relative; background: #a9aa9e; }
|
||||
.relay-arrow i::after { content: ""; position: absolute; right: 0; top: -4px; border-width: 4px 0 4px 7px; border-style: solid; border-color: transparent transparent transparent #a9aa9e; }
|
||||
.steps-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; margin-top: 68px; background: var(--line); border: 1px solid var(--line); }
|
||||
.steps-grid article { min-height: 240px; padding: 32px; background: var(--paper); }
|
||||
.steps-grid article > span { display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; color: var(--ink); background: var(--lime); font-weight: 800; }
|
||||
.steps-grid h3 { margin: 34px 0 10px; font-size: 22px; }
|
||||
.steps-grid p { margin: 0; color: var(--muted); line-height: 1.7; }
|
||||
|
||||
.dark-section { color: white; background: var(--ink); }
|
||||
.inverse .eyebrow { color: var(--lime); }
|
||||
.inverse p { color: #aeb2c1; }
|
||||
.feature-ledger { border-top: 1px solid #34384b; }
|
||||
.feature-ledger article { display: grid; grid-template-columns: 100px 1fr; gap: 24px; align-items: start; padding: 36px 0; border-bottom: 1px solid #34384b; }
|
||||
.ledger-index { color: var(--lime); font-family: ui-monospace, Consolas, monospace; font-size: 15px; }
|
||||
.feature-ledger article div { display: grid; grid-template-columns: .55fr 1fr; gap: 50px; }
|
||||
.feature-ledger h3 { margin: 0; font-size: 25px; }
|
||||
.feature-ledger p { max-width: 720px; margin: 0; color: #aeb2c1; line-height: 1.75; }
|
||||
|
||||
.price-section { background: var(--paper-2); }
|
||||
.price-callout { display: grid; grid-template-columns: 1fr .85fr; border: 1px solid #c6c6b9; border-radius: var(--radius-lg); overflow: hidden; background: var(--white); box-shadow: var(--shadow); }
|
||||
.price-copy { padding: 58px; }
|
||||
.price-copy h2 { font-size: clamp(40px, 4vw, 60px); }
|
||||
.price-copy > p { margin-top: 22px; }
|
||||
.check-list { list-style: none; padding: 0; margin: 34px 0 0; }
|
||||
.check-list li { padding: 12px 0 12px 28px; position: relative; border-bottom: 1px solid #e6e5dc; }
|
||||
.check-list li::before { content: "✓"; position: absolute; left: 0; color: var(--lime-deep); font-weight: 900; }
|
||||
.price-board { display: flex; flex-direction: column; justify-content: center; padding: 42px; color: white; background: var(--ink); }
|
||||
.beta-price-row, .catalog-price-row { display: grid; grid-template-columns: 1fr auto; align-items: baseline; padding: 20px 0; border-bottom: 1px solid #35394b; }
|
||||
.price-board strong { font-size: 34px; letter-spacing: -.04em; }
|
||||
.price-board small { grid-column: 2; color: #9498a8; }
|
||||
.beta-price-row strong { color: var(--lime); font-size: 62px; }
|
||||
.price-board > a { margin-top: 30px; color: var(--lime); font-weight: 750; }
|
||||
|
||||
.trust-section { background: var(--white); }
|
||||
.trust-grid { display: grid; grid-template-columns: .78fr 1.22fr; gap: 80px; align-items: start; }
|
||||
.trust-title h2 { font-size: clamp(40px, 4.4vw, 64px); }
|
||||
.trust-title > p { margin-top: 24px; }
|
||||
.text-link { display: inline-flex; margin-top: 24px; font-weight: 750; border-bottom: 2px solid var(--lime-deep); }
|
||||
.visibility-table { border-top: 2px solid var(--ink); }
|
||||
.visibility-table > div { min-height: 64px; display: grid; grid-template-columns: 1.3fr .7fr 1fr; align-items: center; gap: 20px; border-bottom: 1px solid var(--line); }
|
||||
.visibility-table span { font-weight: 700; }
|
||||
.visibility-table strong { color: var(--lime-deep); }
|
||||
.visibility-table em { color: var(--muted); font-style: normal; }
|
||||
.visibility-head { min-height: 44px !important; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .1em; }
|
||||
.visibility-table > small { display: block; margin-top: 16px; color: var(--muted); }
|
||||
|
||||
.readiness-banner { width: 100%; display: flex; justify-content: space-between; align-items: flex-end; gap: 48px; padding: 70px max(40px, calc((100vw - var(--max)) / 2)); color: white; background: var(--coral); }
|
||||
.readiness-banner h2 { max-width: 750px; margin-top: 20px; font-size: clamp(38px, 4.2vw, 60px); }
|
||||
.readiness-banner p { max-width: 760px; margin: 18px 0 0; line-height: 1.7; color: #fff7f5; }
|
||||
|
||||
.public-footer { padding: 70px max(20px, calc((100vw - var(--max)) / 2)) 28px; color: #babdca; background: #0b0d16; }
|
||||
.footer-grid { display: grid; grid-template-columns: 1.5fr .7fr 1fr 1fr; gap: 50px; }
|
||||
.footer-grid > div { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; }
|
||||
.footer-grid p { margin: 0; font-size: 13px; line-height: 1.7; }
|
||||
.footer-grid strong { color: white; font-size: 13px; }
|
||||
.footer-grid a { font-size: 13px; }
|
||||
.footer-grid a:hover { color: white; }
|
||||
.footer-bottom { display: flex; justify-content: space-between; gap: 20px; margin-top: 60px; padding-top: 22px; border-top: 1px solid #252838; font-size: 11px; }
|
||||
|
||||
.subpage-hero { color: white; background: var(--ink); border-bottom: 1px solid #35394b; }
|
||||
.subpage-hero-inner { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; padding: 100px 0 90px; }
|
||||
.subpage-hero h1 { max-width: 950px; margin: 18px 0 0; font-size: clamp(58px, 8vw, 108px); line-height: .95; letter-spacing: -.07em; }
|
||||
.subpage-hero p { max-width: 760px; margin: 30px 0 0; color: #b7bac7; font-size: 18px; line-height: 1.8; }
|
||||
.pricing-hero { background: var(--ink); }
|
||||
.pricing-catalog-section { background: var(--paper-2); }
|
||||
.pricing-catalog { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: repeat(3, 1fr); align-items: stretch; gap: 16px; }
|
||||
.pricing-card { min-width: 0; display: flex; flex-direction: column; padding: 36px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--white); }
|
||||
.pricing-card-top { display: flex; justify-content: space-between; align-items: center; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.pricing-card h2 { margin: 38px 0 0; font-size: 62px; letter-spacing: -.06em; }
|
||||
.pricing-card h2 small { display: block; margin-top: 6px; color: var(--muted); font-size: 14px; font-weight: 600; letter-spacing: 0; }
|
||||
.pricing-card > p { min-height: 78px; margin: 18px 0 0; color: var(--muted); line-height: 1.7; }
|
||||
.pricing-card .button { width: 100%; margin-top: auto; }
|
||||
.beta-card { border-color: #a7d84b; box-shadow: inset 0 5px 0 var(--lime); }
|
||||
.featured-card { color: white; background: var(--ink); border-color: var(--ink); box-shadow: var(--shadow); }
|
||||
.featured-card .plain-list li { border-color: #34384b; }
|
||||
.featured-card .pricing-card-top, .featured-card > p { color: #aeb2c1; }
|
||||
.plain-list { list-style: none; padding: 0; margin: 28px 0 34px; }
|
||||
.plain-list li { position: relative; padding: 11px 0 11px 25px; border-bottom: 1px solid #e3e2d9; line-height: 1.5; }
|
||||
.plain-list li::before { content: "—"; position: absolute; left: 0; color: var(--lime-deep); font-weight: 900; }
|
||||
.policy-section { background: var(--white); }
|
||||
.policy-grid { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: .55fr 1fr; gap: 90px; }
|
||||
.policy-grid h2 { margin: 12px 0 0; font-size: 58px; letter-spacing: -.055em; }
|
||||
.rules-list { margin: 0; border-top: 2px solid var(--ink); }
|
||||
.rules-list > div { display: grid; grid-template-columns: 180px 1fr; gap: 30px; padding: 24px 0; border-bottom: 1px solid var(--line); }
|
||||
.rules-list dt { font-weight: 800; }
|
||||
.rules-list dd { margin: 0; color: var(--muted); line-height: 1.7; }
|
||||
.comparison-section { background: var(--paper); }
|
||||
.comparison-table { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; border-top: 2px solid var(--ink); }
|
||||
.comparison-table > div { min-height: 72px; display: grid; grid-template-columns: 1.2fr 1fr 1fr; gap: 30px; align-items: center; border-bottom: 1px solid var(--line); }
|
||||
.comparison-table b { font-weight: 650; }
|
||||
.comparison-head { min-height: 48px !important; color: var(--muted); font-size: 12px; }
|
||||
.comparison-head strong { color: var(--ink); }
|
||||
.fine-print { width: min(var(--max), calc(100% - 40px)); margin: 16px auto 0; color: var(--muted); font-size: 12px; }
|
||||
|
||||
.trust-hero { background: #121522; }
|
||||
.boundary-section { background: var(--paper); }
|
||||
.boundary-grid { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
|
||||
.boundary-card { min-height: 420px; display: flex; flex-direction: column; padding: 34px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--white); }
|
||||
.boundary-card h2 { margin: auto 0 0; font-size: 32px; letter-spacing: -.04em; }
|
||||
.boundary-card .plain-list { margin-bottom: 0; }
|
||||
.boundary-icon { width: fit-content; padding: 7px 10px; border: 1px solid currentColor; border-radius: 999px; font-family: ui-monospace, Consolas, monospace; font-size: 11px; font-weight: 800; letter-spacing: .1em; }
|
||||
.cloud-boundary-card { color: white; background: var(--ink); border-color: var(--ink); }
|
||||
.cloud-boundary-card .plain-list li { border-color: #363a4c; }
|
||||
.evidence-card { background: var(--lime); border-color: #a6d84c; }
|
||||
.evidence-card p { color: #3e462d; line-height: 1.7; }
|
||||
.mutable-pwa-section { color: white; background: #2c365e; }
|
||||
.mutable-pwa-grid { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: .82fr 1.18fr; gap: 90px; }
|
||||
.mutable-pwa-grid .eyebrow { color: var(--lime); }
|
||||
.mutable-pwa-grid h2 { margin: 14px 0 0; font-size: clamp(46px, 5vw, 72px); line-height: 1; letter-spacing: -.06em; }
|
||||
.mutable-pwa-grid p { margin: 0; color: #cbd1e8; font-size: 18px; line-height: 1.85; }
|
||||
.evidence-checks { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 32px; }
|
||||
.evidence-checks span { padding: 14px; border: 1px solid rgb(255 255 255 / 18%); border-radius: 9px; color: #e4e8f6; font-size: 13px; }
|
||||
.claim-section { background: var(--white); }
|
||||
.claim-grid { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--line); border: 1px solid var(--line); }
|
||||
.claim-grid > div { min-height: 360px; display: flex; flex-direction: column; justify-content: space-between; padding: 42px; background: var(--white); }
|
||||
.claim-grid span { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.claim-grid h3 { margin: 0; font-size: 31px; line-height: 1.3; letter-spacing: -.035em; }
|
||||
.claim-do { box-shadow: inset 0 7px 0 var(--lime); }
|
||||
.claim-dont { box-shadow: inset 0 7px 0 var(--coral); }
|
||||
.trust-cta { padding: 70px max(20px, calc((100vw - var(--max)) / 2)); display: flex; align-items: center; justify-content: space-between; gap: 40px; color: white; background: var(--ink); }
|
||||
.trust-cta h2 { margin: 0; font-size: 48px; letter-spacing: -.05em; }
|
||||
.trust-cta p { margin: 10px 0 0; color: #aeb2c1; }
|
||||
.trust-cta-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
||||
|
||||
.privacy-hero { background: radial-gradient(circle at 78% 20%, rgb(202 255 105 / 24%), transparent 30%), var(--ink); }
|
||||
.privacy-hero h1 { color: white; }
|
||||
.privacy-hero p { color: #c7cad5; }
|
||||
.data-map-grid { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.data-map-card { min-height: 520px; display: flex; flex-direction: column; padding: 30px; border: 1px solid var(--line); border-radius: 14px; background: white; }
|
||||
.data-map-card-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.data-map-card-heading > span { color: var(--muted); font-family: ui-monospace, Consolas, monospace; font-size: 10px; font-weight: 800; letter-spacing: .08em; }
|
||||
.data-map-card h3 { margin: 34px 0 10px; font-size: 30px; letter-spacing: -.04em; }
|
||||
.data-map-card > p { margin: 0; color: var(--muted); line-height: 1.7; }
|
||||
.data-map-dormant { background: #f3f2eb; }
|
||||
.data-example-list { display: flex; flex-wrap: wrap; gap: 7px; margin: 24px 0; padding: 0; list-style: none; }
|
||||
.data-example-list li { padding: 7px 9px; border: 1px solid #d7d7cc; border-radius: 999px; color: #454852; background: #f8f8f3; font-size: 10px; }
|
||||
.data-map-details { margin: auto 0 0; border-top: 1px solid var(--line); }
|
||||
.data-map-details > div { display: grid; grid-template-columns: 74px 1fr; gap: 18px; padding: 16px 0; border-bottom: 1px solid #e8e7de; }
|
||||
.data-map-details dt { color: var(--muted); font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.data-map-details dd { margin: 0; font-size: 12px; line-height: 1.65; }
|
||||
.not-collected-section { background: var(--ink); color: white; }
|
||||
.not-collected-section .eyebrow { color: var(--lime); }
|
||||
.not-collected-list { margin: 0; padding: 0; list-style: none; border-top: 1px solid #454959; }
|
||||
.not-collected-list li { position: relative; padding: 18px 0 18px 34px; border-bottom: 1px solid #343847; color: #eef0f6; }
|
||||
.not-collected-list li::before { content: "×"; position: absolute; left: 3px; top: 17px; color: var(--coral); font-size: 20px; font-weight: 800; }
|
||||
.not-collected-list strong, .not-collected-list span { display: block; }
|
||||
.not-collected-list span { margin-top: 5px; color: #aeb2c1; font-size: 12px; line-height: 1.6; }
|
||||
.privacy-boundary-note { margin: 24px 0 0; color: #adb1bf; font-size: 12px; line-height: 1.7; }
|
||||
.privacy-action-grid { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
.privacy-action-grid article { min-height: 280px; display: flex; flex-direction: column; padding: 28px; border: 1px solid var(--line); border-radius: 14px; background: #f8f8f2; }
|
||||
.privacy-action-grid article > span { color: var(--lime-deep); font-family: ui-monospace, Consolas, monospace; font-weight: 800; }
|
||||
.privacy-action-grid h3 { margin: 46px 0 10px; font-size: 24px; letter-spacing: -.035em; }
|
||||
.privacy-action-grid p { margin: 0; color: var(--muted); line-height: 1.7; }
|
||||
.privacy-action-grid a { margin-top: auto; padding-top: 22px; font-weight: 800; }
|
||||
.privacy-open-items { width: min(var(--max), calc(100% - 40px)); margin: 0 auto 100px; display: grid; grid-template-columns: .9fr 1.1fr; gap: 80px; padding: 40px; border-left: 6px solid var(--coral); background: #fff0eb; }
|
||||
.privacy-open-items h2 { margin: 15px 0 0; font-size: 36px; letter-spacing: -.045em; }
|
||||
.privacy-open-items > p { margin: 0; color: #65463f; line-height: 1.8; }
|
||||
|
||||
.readiness-hero { background: var(--coral); }
|
||||
.readiness-hero .status-pill { margin-bottom: 18px; }
|
||||
.readiness-hero p { color: #fff5f2; }
|
||||
.gates-section { background: var(--paper); }
|
||||
.gates-heading { width: min(var(--max), calc(100% - 40px)); margin: 0 auto 58px; display: grid; grid-template-columns: 1fr .7fr; gap: 70px; align-items: end; }
|
||||
.gates-heading h2 { margin: 12px 0 0; font-size: 64px; letter-spacing: -.06em; }
|
||||
.gates-heading p { margin: 0; color: var(--muted); line-height: 1.75; }
|
||||
.gates-grid { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
.gate-card { min-height: 250px; padding: 28px; display: flex; flex-direction: column; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--white); }
|
||||
.gate-card-top { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.gate-card h3 { margin: 34px 0 28px; font-size: 23px; line-height: 1.3; letter-spacing: -.025em; }
|
||||
.gate-card dl { margin: auto 0 0; }
|
||||
.gate-card dl div { display: grid; grid-template-columns: 70px 1fr; padding-top: 8px; border-top: 1px solid #e5e4db; font-size: 12px; }
|
||||
.gate-card dt { color: var(--muted); }
|
||||
.gate-card dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
|
||||
.gate-card dd a { color: #3b5cc0; text-decoration: underline; }
|
||||
.legal-section { background: var(--white); }
|
||||
.legal-ledger { width: min(var(--max), calc(100% - 40px)); margin: 0 auto; display: grid; grid-template-columns: 1fr 1fr; border-top: 2px solid var(--ink); }
|
||||
.legal-ledger article { min-height: 310px; padding: 34px; border-bottom: 1px solid var(--line); }
|
||||
.legal-ledger article:nth-child(odd) { border-right: 1px solid var(--line); }
|
||||
.legal-ledger article > span { color: var(--lime-deep); font-family: ui-monospace, Consolas, monospace; font-weight: 800; }
|
||||
.legal-ledger h3 { margin: 35px 0 12px; font-size: 26px; }
|
||||
.legal-ledger p { color: var(--muted); line-height: 1.7; }
|
||||
.legal-ledger a { display: inline-block; margin-top: 16px; font-weight: 750; border-bottom: 1px solid currentColor; }
|
||||
|
||||
.cloud-shell { min-height: 100vh; display: grid; grid-template-columns: 250px 1fr; background: #ecece4; }
|
||||
.cloud-sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; padding: 24px 18px; color: #c6c9d4; background: #111421; border-right: 1px solid #2a2e3e; }
|
||||
.cloud-brand { padding: 0 8px; color: white; }
|
||||
.prototype-notice { margin: 24px 8px 18px; display: flex; align-items: center; gap: 9px; color: #8d92a3; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.notice-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--coral); box-shadow: 0 0 0 4px rgb(255 121 104 / 10%); }
|
||||
.cloud-nav { display: flex; flex-direction: column; gap: 4px; }
|
||||
.cloud-nav a { min-height: 46px; display: flex; align-items: center; gap: 12px; padding: 0 13px; border-radius: 9px; color: #9499aa; font-size: 14px; font-weight: 650; }
|
||||
.cloud-nav a > span { width: 22px; color: #686e82; font-family: ui-monospace, Consolas, monospace; text-align: center; }
|
||||
.cloud-nav a:hover { color: white; background: #1a1e2d; }
|
||||
.cloud-nav a.active { color: var(--ink); background: var(--lime); }
|
||||
.cloud-nav a.active > span { color: var(--ink); }
|
||||
.sidebar-account { margin-top: auto; min-width: 0; display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 14px 8px 0; border-top: 1px solid #2a2e3e; }
|
||||
.account-avatar { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 11px; color: var(--ink); background: var(--cyan); font-weight: 850; }
|
||||
.sidebar-account > span:nth-child(2) { min-width: 0; }
|
||||
.sidebar-account strong, .sidebar-account small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.sidebar-account strong { color: white; font-size: 12px; }
|
||||
.sidebar-account small { margin-top: 3px; color: #767b8e; font-size: 10px; }
|
||||
.sidebar-account > a { color: #757a8d; }
|
||||
.cloud-main { min-width: 0; }
|
||||
.connectivity-banner { min-height: 46px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 10px 24px; color: #604600; background: #fff0b8; border-bottom: 1px solid #d7b94f; font-size: 12px; text-align: center; }
|
||||
.connectivity-banner strong { flex: none; text-transform: uppercase; letter-spacing: .05em; }
|
||||
.connectivity-banner span { line-height: 1.5; }
|
||||
.demo-banner { min-height: 38px; display: flex; align-items: center; justify-content: center; padding: 8px 20px; color: #3f4e18; background: #e0f7b5; border-bottom: 1px solid #c1de88; font-size: 11px; text-align: center; }
|
||||
.service-incident-banner { min-height: 46px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 10px 24px; border-bottom: 1px solid; font-size: 12px; }
|
||||
.service-incident-banner span { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.service-incident-banner strong { flex: none; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.service-incident-banner a { flex: none; color: inherit; font-weight: 750; text-decoration: none; }
|
||||
.service-incident-banner.incident-maintenance { color: #3e4c77; background: #eef1ff; border-color: #cbd2f0; }
|
||||
.service-incident-banner.incident-degraded { color: #684b0c; background: #fff2ca; border-color: #e6cd78; }
|
||||
.service-incident-banner.incident-outage { color: #81291f; background: #ffe5df; border-color: #e6aaa0; }
|
||||
.cloud-page { width: min(1260px, calc(100% - 64px)); margin: 0 auto; padding: 50px 0 90px; }
|
||||
.narrow-cloud-page { max-width: 1040px; }
|
||||
.page-heading { min-height: 150px; display: flex; justify-content: space-between; align-items: flex-end; gap: 34px; margin-bottom: 38px; }
|
||||
.page-heading h1 { margin: 10px 0 0; font-size: clamp(42px, 5vw, 70px); line-height: 1; letter-spacing: -.06em; }
|
||||
.page-heading p { max-width: 720px; margin: 15px 0 0; color: var(--muted); line-height: 1.7; }
|
||||
.page-actions { flex: none; padding-bottom: 4px; }
|
||||
.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
|
||||
.metric-card { min-height: 180px; display: flex; flex-direction: column; padding: 24px; border: 1px solid #d2d2c6; border-radius: 14px; background: var(--white); }
|
||||
.metric-card > span { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.metric-card strong { margin-top: auto; font-size: 32px; letter-spacing: -.04em; }
|
||||
.metric-card small { margin-top: 7px; color: var(--muted); line-height: 1.45; }
|
||||
.accent-card { background: var(--lime); border-color: #a8d853; }
|
||||
.accent-card > span, .accent-card small { color: #425223; }
|
||||
.dashboard-columns { display: grid; grid-template-columns: 1.2fr .8fr; gap: 12px; margin-top: 12px; }
|
||||
.panel { min-width: 0; padding: 26px; border: 1px solid #d2d2c6; border-radius: 14px; background: var(--white); }
|
||||
.full-panel { margin-top: 12px; }
|
||||
.panel-heading { min-height: 58px; display: flex; justify-content: space-between; align-items: flex-start; gap: 24px; padding-bottom: 18px; border-bottom: 1px solid #e3e2d9; }
|
||||
.panel-heading span { color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .1em; }
|
||||
.panel-heading h2 { margin: 5px 0 0; font-size: 24px; letter-spacing: -.035em; }
|
||||
.panel-heading > a { color: #4059ac; font-size: 12px; font-weight: 750; }
|
||||
.host-list { display: flex; flex-direction: column; }
|
||||
.host-row { min-width: 0; min-height: 82px; display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; gap: 14px; align-items: center; border-bottom: 1px solid #e8e7df; }
|
||||
.host-row:last-child { border-bottom: 0; }
|
||||
.host-icon { width: 40px; height: 40px; display: grid; place-items: center; border-radius: 11px; font-family: ui-monospace, Consolas, monospace; font-weight: 850; }
|
||||
.host-windows { color: #27419b; background: #d9e1ff; }
|
||||
.host-linux { color: #2f6b2a; background: #dbf2c8; }
|
||||
.host-row > div { min-width: 0; }
|
||||
.host-row strong, .host-row small { display: block; }
|
||||
.host-row > div:nth-child(2) strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.host-row small { margin-top: 4px; color: var(--muted); font-size: 10px; }
|
||||
.host-state { text-align: right; }
|
||||
.host-state .status-pill { margin-left: auto; }
|
||||
.attention-panel { background: #f8f7f0; }
|
||||
.attention-list article { display: grid; grid-template-columns: auto 1fr; gap: 12px; align-items: start; padding: 18px 0; border-bottom: 1px solid #e3e2d9; }
|
||||
.attention-list article:last-child { border-bottom: 0; }
|
||||
.attention-list strong { font-size: 14px; }
|
||||
.attention-list p { margin: 6px 0 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
.empty-state { min-height: 300px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; text-align: center; }
|
||||
.compact-empty { min-height: 330px; }
|
||||
.empty-symbol { width: 52px; height: 52px; display: grid; place-items: center; margin-bottom: 18px; border: 1px solid var(--line); border-radius: 16px; color: var(--muted); background: var(--paper); font-size: 25px; }
|
||||
.empty-state h3 { margin: 0; font-size: 20px; }
|
||||
.empty-state p { max-width: 500px; margin: 10px 0 22px; color: var(--muted); line-height: 1.6; }
|
||||
.empty-inline { padding: 34px 0 12px; color: var(--muted); text-align: center; }
|
||||
.path-panel { display: grid; grid-template-columns: .72fr 1.28fr; gap: 70px; margin-top: 12px; padding: 42px; color: white; background: var(--ink); border-radius: 14px; }
|
||||
.path-panel h2 { margin: 14px 0 0; font-size: 38px; line-height: 1.1; letter-spacing: -.05em; }
|
||||
.path-panel ol { list-style: none; padding: 0; margin: 0; }
|
||||
.path-panel li { display: grid; grid-template-columns: 40px 1fr; gap: 12px; padding: 18px 0; border-bottom: 1px solid #34384a; opacity: .55; }
|
||||
.path-panel li.current, .path-panel li.done { opacity: 1; }
|
||||
.path-panel li > span { color: var(--lime); font-family: ui-monospace, Consolas, monospace; }
|
||||
.path-panel li p { margin: 5px 0 0; color: #979bac; font-size: 12px; }
|
||||
.entitlement-bar { display: grid; grid-template-columns: 1.4fr repeat(3, .7fr); margin-bottom: 12px; color: white; background: var(--ink); border-radius: 14px; }
|
||||
.entitlement-bar > div { min-height: 96px; display: flex; flex-direction: column; justify-content: center; padding: 18px 24px; border-right: 1px solid #35394c; }
|
||||
.entitlement-bar > div:last-child { border-right: 0; }
|
||||
.entitlement-bar span { color: #888d9f; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.entitlement-bar strong { margin-top: 7px; font-size: 20px; }
|
||||
.detailed-host { grid-template-columns: 44px minmax(150px, 1fr) .55fr .8fr auto auto; }
|
||||
.row-label { display: block; color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.detailed-host > div strong { margin-top: 4px; font-size: 12px; }
|
||||
.host-contact-note { margin: 14px 0 0; color: var(--muted); font-size: 12px; line-height: 1.65; }
|
||||
.pairing-list article, .order-list article { min-height: 78px; display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; gap: 22px; align-items: center; border-bottom: 1px solid #e5e4dc; }
|
||||
.pairing-list article:last-child, .order-list article:last-child { border-bottom: 0; }
|
||||
.pairing-list strong, .pairing-list small, .order-list strong, .order-list small { display: block; }
|
||||
.recovery-options { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin-top: 22px; border: 1px solid var(--line); background: var(--line); }
|
||||
.recovery-options article { min-height: 220px; padding: 24px; background: var(--white); }
|
||||
.recovery-options h3 { margin: 22px 0 8px; font-size: 20px; }
|
||||
.recovery-options p { margin: 0; color: var(--muted); line-height: 1.65; }
|
||||
.pairing-list small, .order-list small, .pairing-list article > span, .order-list article > span { margin-top: 4px; color: var(--muted); font-size: 10px; }
|
||||
.pairing-cancel-action { min-width: 0; }
|
||||
.pairing-cancel-action .button { white-space: nowrap; }
|
||||
.pairing-cancel-action .form-error { display: block; max-width: 260px; margin-top: 8px; white-space: normal; }
|
||||
|
||||
.form-layout { display: grid; grid-template-columns: 1fr .72fr; gap: 12px; align-items: start; }
|
||||
.form-panel { padding: 36px; }
|
||||
.cloud-form { display: flex; flex-direction: column; gap: 26px; }
|
||||
.cloud-form label > span, .cloud-form legend, .quote-controls label > span { display: block; margin-bottom: 9px; font-size: 12px; font-weight: 780; }
|
||||
.cloud-form input, .cloud-form select, .cloud-form textarea, .quote-controls input, .quote-controls select { width: 100%; min-height: 48px; padding: 0 14px; border: 1px solid #c9c9be; border-radius: 9px; color: var(--ink); background: white; }
|
||||
.cloud-form textarea { min-height: 180px; padding-block: 13px; resize: vertical; line-height: 1.6; }
|
||||
.cloud-form label > small { display: block; margin-top: 8px; color: var(--muted); line-height: 1.5; }
|
||||
.cloud-form fieldset { padding: 0; border: 0; }
|
||||
.choice-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.choice-grid label { min-height: 120px; display: flex; flex-direction: column; justify-content: flex-end; padding: 18px; border: 1px solid #cecec3; border-radius: 10px; cursor: pointer; }
|
||||
.choice-grid label.selected { border-color: #91bc3f; background: #f0ffda; box-shadow: inset 0 0 0 1px #91bc3f; }
|
||||
.choice-grid input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.choice-grid strong { font-size: 18px; }
|
||||
.choice-grid small { margin-top: 4px; color: var(--muted); }
|
||||
.form-error { margin: 0; padding: 12px 14px; border-radius: 8px; color: #8d2834; background: #ffe0e2; }
|
||||
.form-aside { padding: 34px; color: white; background: var(--ink); border-radius: 14px; }
|
||||
.form-aside ol { list-style: none; padding: 0; margin: 30px 0 0; }
|
||||
.form-aside li { padding: 20px 0; border-bottom: 1px solid #34384a; }
|
||||
.form-aside li::marker { color: var(--lime); }
|
||||
.form-aside p { margin: 7px 0 0; color: #9da1b1; font-size: 12px; line-height: 1.6; }
|
||||
.pairing-result { display: flex; flex-direction: column; align-items: flex-start; }
|
||||
.pairing-result h2 { margin: 12px 0; font-size: 34px; }
|
||||
.pairing-result > p { color: var(--muted); line-height: 1.6; }
|
||||
.pairing-progress { width: 100%; display: flex; flex-direction: column; gap: 5px; margin: 10px 0 4px; padding: 13px 15px; border-left: 4px solid var(--lime-deep); background: #f0f7df; }
|
||||
.pairing-progress span, .pairing-complete > small { color: var(--muted); font-size: 12px; line-height: 1.55; }
|
||||
.pairing-complete > small { margin: -10px 0 22px; overflow-wrap: anywhere; }
|
||||
.pairing-result-actions { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 10px; }
|
||||
.registration-steps { width: 100%; margin: 18px 0 4px; padding: 0; list-style: none; counter-reset: registration-step; }
|
||||
.registration-steps > li { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; padding: 24px 0; border-top: 1px solid var(--line); counter-increment: registration-step; }
|
||||
.registration-steps > li > strong::before { content: counter(registration-step) ". "; color: var(--lime-deep); font-family: ui-monospace, Consolas, monospace; }
|
||||
.registration-steps small { color: var(--muted); line-height: 1.55; }
|
||||
.pairing-copy-field { display: flex; width: 100%; flex-direction: column; gap: 7px; }
|
||||
.pairing-copy-field > span { color: var(--muted); font-size: 12px; font-weight: 750; }
|
||||
.pairing-copy-field input, .pairing-copy-field textarea { width: 100%; padding: 14px; border: 1px solid #b7b9ae; border-radius: 9px; color: var(--ink); background: #f7f7f0; font: 12px/1.6 ui-monospace, "SFMono-Regular", Consolas, monospace; resize: vertical; }
|
||||
.pairing-copy-field input { font-size: 14px; font-weight: 750; overflow-wrap: anywhere; }
|
||||
.pairing-copy-field.compact { max-width: 420px; }
|
||||
.pairing-copy-field input:focus, .pairing-copy-field textarea:focus { outline: 3px solid color-mix(in srgb, var(--lime) 44%, transparent); outline-offset: 2px; }
|
||||
.pairing-download-link { margin-top: 0; }
|
||||
.prototype-callout { display: flex; flex-direction: column; gap: 6px; margin: 18px 0 24px; padding: 16px; border-left: 4px solid var(--coral); background: #fff0ed; }
|
||||
.prototype-callout span { color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
|
||||
.billing-status-panel { display: grid; grid-template-columns: 1fr .8fr; gap: 60px; align-items: center; padding: 36px; color: white; background: var(--ink); border-radius: 14px; }
|
||||
.billing-status-panel h2 { margin: 15px 0 6px; font-size: 42px; letter-spacing: -.05em; }
|
||||
.billing-status-panel p { margin: 0; color: #aeb2c1; }
|
||||
.billing-status-panel dl { margin: 0; }
|
||||
.billing-status-panel dl div { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #363a4b; }
|
||||
.billing-status-panel dt { color: #898e9f; }
|
||||
.billing-status-panel dd { margin: 0; font-weight: 750; }
|
||||
.billing-columns { grid-template-columns: .85fr 1.15fr; }
|
||||
.mini-price-list { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; margin-top: 20px; background: var(--line); border: 1px solid var(--line); }
|
||||
.mini-price-list article { padding: 20px; background: var(--white); }
|
||||
.mini-price-list span, .mini-price-list small { display: block; color: var(--muted); font-size: 10px; }
|
||||
.mini-price-list strong { display: block; margin: 12px 0 3px; font-size: 30px; }
|
||||
.billing-rules { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-top: 22px; color: var(--muted); font-size: 11px; }
|
||||
.quote-builder { padding-top: 20px; }
|
||||
.quote-controls { display: grid; grid-template-columns: 1fr .6fr 1fr; gap: 12px; align-items: end; }
|
||||
.quote-total { min-height: 80px; display: flex; flex-direction: column; justify-content: center; padding: 10px 16px; border-left: 3px solid var(--lime-deep); background: #f1f1e9; }
|
||||
.quote-total span, .quote-total small { color: var(--muted); font-size: 9px; }
|
||||
.quote-total strong { margin: 4px 0; font-size: 25px; }
|
||||
.quote-builder > .button { width: 100%; margin-top: 18px; }
|
||||
.form-note { margin: 12px 0 0; color: var(--muted); font-size: 10px; line-height: 1.5; }
|
||||
.quote-result { margin-top: 18px; display: grid; grid-template-columns: 1fr auto; gap: 15px; padding: 18px; border: 1px solid #bbd887; border-radius: 10px; background: #f1ffdd; }
|
||||
.quote-result > div { min-width: 0; }
|
||||
.quote-result strong, .quote-result span { display: block; }
|
||||
.quote-result span { margin-top: 5px; color: var(--muted); font-size: 9px; overflow-wrap: anywhere; }
|
||||
.quote-result .button { grid-column: 1 / -1; width: 100%; }
|
||||
|
||||
.security-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
.security-card { min-height: 300px; display: flex; flex-direction: column; }
|
||||
.card-index { margin-bottom: 24px; color: #aaaca3; font-family: ui-monospace, Consolas, monospace; }
|
||||
.security-card h2 { margin: auto 0 12px; font-size: 24px; overflow-wrap: anywhere; }
|
||||
.security-card p { margin: 0; color: var(--muted); line-height: 1.65; }
|
||||
.safety-actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; margin-top: 20px; background: var(--line); border: 1px solid var(--line); }
|
||||
.safety-actions article { min-height: 230px; display: flex; flex-direction: column; padding: 24px; background: var(--white); }
|
||||
.safety-actions p { color: var(--muted); line-height: 1.6; }
|
||||
.safety-actions button { min-height: 42px; margin-top: auto; border: 1px solid var(--line); border-radius: 8px; color: var(--muted); background: #eee; }
|
||||
.safety-actions article > .button { margin-top: auto; }
|
||||
.deletion-request-form, .deletion-request-state { display: flex; flex-direction: column; gap: 12px; margin-top: auto; }
|
||||
.deletion-request-form label > span { display: block; margin-bottom: 6px; color: #424551; font-size: 10px; font-weight: 750; }
|
||||
.deletion-request-form textarea { width: 100%; min-height: 72px; padding: 10px; border: 1px solid #c9c9be; border-radius: 8px; resize: vertical; }
|
||||
.deletion-request-form .deletion-confirmation { display: flex; align-items: flex-start; gap: 8px; color: var(--muted); font-size: 10px; line-height: 1.5; }
|
||||
.deletion-request-form .deletion-confirmation span { margin: 0; font-weight: 500; }
|
||||
.deletion-request-form .deletion-confirmation input { margin-top: 2px; flex: none; }
|
||||
.deletion-request-state > span { align-self: flex-start; padding: 5px 8px; border-radius: 999px; color: #76570e; background: #fff0be; font-size: 9px; font-weight: 800; }
|
||||
.deletion-request-state small { color: var(--muted); }
|
||||
.deletion-request-state p { margin: 0; overflow-wrap: anywhere; }
|
||||
.inline-callout { margin-top: 12px; display: flex; align-items: center; justify-content: space-between; gap: 30px; padding: 24px; border: 1px solid #cfd0c4; border-radius: 14px; background: #f8f8f2; }
|
||||
.inline-callout p { margin: 5px 0 0; color: var(--muted); }
|
||||
|
||||
.admin-metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin-bottom: 12px; border: 1px solid #cfd0c4; border-radius: 14px; overflow: hidden; background: #cfd0c4; }
|
||||
.admin-metrics article { min-height: 140px; display: flex; flex-direction: column; padding: 22px; background: var(--ink); color: white; }
|
||||
.admin-metrics span { color: #8f94a6; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.admin-metrics strong { margin-top: auto; font-size: 27px; letter-spacing: -.035em; }
|
||||
.admin-metrics small { margin-top: 6px; color: #9297a7; line-height: 1.4; }
|
||||
.operations-panel { margin-bottom: 12px; }
|
||||
.operations-intro { max-width: 920px; color: var(--muted); line-height: 1.7; }
|
||||
.operations-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 24px; }
|
||||
.operations-grid article { min-height: 190px; display: flex; flex-direction: column; padding: 22px; border: 1px solid var(--line); border-radius: 12px; background: #f8f8f2; }
|
||||
.operations-grid article > span { color: var(--muted); font-size: 10px; font-weight: 750; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.operations-grid article > strong { margin-top: auto; font-size: 30px; letter-spacing: -.04em; }
|
||||
.operations-grid article > small { margin-top: 9px; color: var(--muted); line-height: 1.55; }
|
||||
.operations-grid .operations-unavailable { color: white; background: var(--ink); border-color: var(--ink); }
|
||||
.operations-grid .operations-unavailable > span, .operations-grid .operations-unavailable > small { color: #aeb1bf; }
|
||||
.daemon-contact-grid article:nth-child(2) { background: #f0f8e2; border-color: #c7dda3; }
|
||||
.daemon-contact-grid article:nth-child(4), .daemon-contact-grid article:nth-child(6) { background: #fff0ec; border-color: #efc1b8; }
|
||||
.daemon-contact-attention { margin-top: 26px; }
|
||||
.daemon-contact-attention h3 { margin: 0 0 10px; font-size: 16px; }
|
||||
.operations-footnote { margin: 18px 0 0; color: var(--muted); font-size: 10px; line-height: 1.6; }
|
||||
.admin-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.admin-module { min-height: 550px; }
|
||||
.maintenance-status { display: grid; grid-template-columns: 1fr 1fr 90px; gap: 8px; margin-top: 18px; }
|
||||
.maintenance-status > div { min-width: 0; padding: 12px; border: 1px solid var(--line); border-radius: 9px; background: #f8f8f2; }
|
||||
.maintenance-status span, .maintenance-status strong { display: block; }
|
||||
.maintenance-status span { color: var(--muted); font-size: 9px; font-weight: 750; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.maintenance-status strong { margin-top: 6px; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.maintenance-status-detail { margin: 10px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
|
||||
.admin-form { display: flex; flex-direction: column; gap: 17px; padding-top: 22px; }
|
||||
.admin-form label > span { display: block; margin-bottom: 7px; color: #424551; font-size: 11px; font-weight: 750; }
|
||||
.admin-form input:not([type="checkbox"]), .admin-form select, .admin-form textarea { width: 100%; min-height: 44px; padding: 10px 12px; border: 1px solid #c9c9be; border-radius: 8px; color: var(--ink); background: white; }
|
||||
.admin-form textarea { min-height: 80px; resize: vertical; line-height: 1.5; }
|
||||
.retention-scope-list { display: grid; gap: 7px; }
|
||||
.retention-scope-list span { padding: 10px 12px; border-left: 3px solid var(--lime-deep); color: #4b4e59; background: #f4f4ed; font-size: 11px; line-height: 1.5; }
|
||||
.admin-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.switch-row { min-height: 76px; display: flex; align-items: center; gap: 13px; padding: 14px; border: 1px solid #d4d4c8; border-radius: 9px; background: #f6f6ef; }
|
||||
.switch-row input { width: 42px; height: 24px; flex: none; accent-color: var(--lime-deep); }
|
||||
.switch-row span { margin: 0 !important; }
|
||||
.switch-row strong, .switch-row small { display: block; }
|
||||
.switch-row small { margin-top: 4px; color: var(--muted); font-weight: 400; }
|
||||
.form-success { margin: 0; padding: 12px 14px; border-radius: 8px; color: #2e6317; background: #e1f6c3; }
|
||||
.feedback-layout { display: grid; grid-template-columns: 1fr .62fr; gap: 12px; align-items: start; }
|
||||
.feedback-form { padding-top: 24px; }
|
||||
.feedback-aside h2 { margin: 14px 0 0; font-size: 30px; letter-spacing: -.04em; }
|
||||
.feedback-list > article, .admin-feedback-list > article { padding: 24px 0; border-bottom: 1px solid #e4e3da; }
|
||||
.feedback-list > article:last-child, .admin-feedback-list > article:last-child { border-bottom: 0; }
|
||||
.feedback-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; color: var(--muted); font-size: 11px; }
|
||||
.feedback-list > article > p, .admin-feedback-list > article > p { margin: 16px 0 0; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.feedback-response { margin-top: 18px; padding: 16px 18px; border-left: 4px solid var(--lime-deep); background: #f0f8e2; }
|
||||
.feedback-response strong { font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.feedback-response p { margin: 7px 0 0; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.feedback-admin-form { max-width: 720px; padding: 18px; margin-top: 18px; border: 1px solid #dddcd2; border-radius: 10px; background: #f8f8f2; }
|
||||
.admin-gate-table article { min-height: 66px; display: grid; grid-template-columns: 46px minmax(0, 1fr) auto 120px; gap: 16px; align-items: center; border-bottom: 1px solid #e4e3da; }
|
||||
.admin-gate-table article:last-child { border-bottom: 0; }
|
||||
.gate-priority { font-family: ui-monospace, Consolas, monospace; font-weight: 850; }
|
||||
.admin-gate-table strong, .admin-gate-table small { display: block; }
|
||||
.admin-gate-table small { margin-top: 4px; color: var(--muted); font-size: 9px; }
|
||||
.admin-gate-table article > span:last-child { color: var(--muted); font-size: 11px; text-align: right; }
|
||||
.audit-list article { min-height: 68px; display: grid; grid-template-columns: 130px 160px minmax(150px, 1fr) 1fr 150px; gap: 14px; align-items: center; border-bottom: 1px solid #e4e3da; font-size: 11px; }
|
||||
.audit-list article:last-child { border-bottom: 0; }
|
||||
.audit-list span { color: var(--muted); overflow-wrap: anywhere; }
|
||||
.audit-list p { margin: 0; line-height: 1.5; }
|
||||
.audit-list code { color: #495897; overflow-wrap: anywhere; }
|
||||
|
||||
.status-page { padding-bottom: 90px; background: #f7f7f1; }
|
||||
.status-hero { min-height: 430px; display: flex; flex-direction: column; justify-content: flex-end; padding: 80px max(40px, calc((100vw - var(--max)) / 2)); border-bottom: 1px solid var(--line); }
|
||||
.status-hero-operational { background: linear-gradient(135deg, #ecf8d9 0%, #f8f8f1 70%); }
|
||||
.status-hero-maintenance { background: linear-gradient(135deg, #e6ebff 0%, #f8f8f1 70%); }
|
||||
.status-hero-degraded { background: linear-gradient(135deg, #fff0b9 0%, #f8f8f1 70%); }
|
||||
.status-hero-outage { background: linear-gradient(135deg, #ffd8d0 0%, #f8f8f1 70%); }
|
||||
.status-hero h1 { max-width: 900px; margin: 22px 0 0; font-size: clamp(42px, 6vw, 78px); line-height: 1.02; letter-spacing: -.055em; }
|
||||
.status-hero p { max-width: 780px; margin: 22px 0 0; color: var(--muted); font-size: 16px; line-height: 1.75; }
|
||||
.status-hero small { margin-top: 30px; color: var(--muted); }
|
||||
.status-section { width: min(var(--max), calc(100% - 40px)); margin: 70px auto 0; }
|
||||
.download-page { padding-bottom: 0; background: var(--paper); }
|
||||
.download-hero { min-height: 500px; display: flex; flex-direction: column; justify-content: flex-end; padding: 90px max(40px, calc((100vw - var(--max)) / 2)); color: white; background: radial-gradient(circle at 78% 22%, rgb(202 255 105 / 22%), transparent 30%), var(--ink); }
|
||||
.download-hero h1 { max-width: 900px; margin: 24px 0 0; font-size: clamp(48px, 7vw, 88px); line-height: .98; letter-spacing: -.06em; }
|
||||
.download-hero p { max-width: 780px; margin: 24px 0 0; color: #c4c7d3; font-size: 17px; line-height: 1.75; }
|
||||
.download-hero small { margin-top: 28px; color: #9ca0af; }
|
||||
.download-section { padding: 90px max(20px, calc((100vw - var(--max)) / 2)); }
|
||||
.download-heading { display: grid; grid-template-columns: 1fr .8fr; gap: 16px 60px; align-items: end; margin-bottom: 46px; }
|
||||
.download-heading .eyebrow { grid-column: 1 / -1; }
|
||||
.download-heading h2 { margin: 0; font-size: clamp(38px, 4vw, 60px); line-height: 1; letter-spacing: -.05em; }
|
||||
.download-heading p { margin: 0; color: var(--muted); line-height: 1.7; }
|
||||
.download-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
||||
.download-card { display: flex; min-width: 0; flex-direction: column; align-items: flex-start; padding: 28px; border: 1px solid var(--line); border-radius: 14px; background: white; box-shadow: 0 14px 36px rgb(18 21 34 / 6%); }
|
||||
.download-platform { color: var(--lime-deep); font: 800 11px/1 ui-monospace, Consolas, monospace; letter-spacing: .12em; }
|
||||
.download-card h3 { margin: 18px 0 8px; font-size: 24px; }
|
||||
.download-filename { min-height: 44px; color: var(--muted); font-size: 11px; overflow-wrap: anywhere; }
|
||||
.download-card > .button { width: 100%; margin-top: 24px; }
|
||||
.checksum-block { width: 100%; margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--line); }
|
||||
.checksum-block strong { display: block; margin-bottom: 8px; font-size: 12px; }
|
||||
.checksum-block code { display: block; color: var(--muted); font-size: 10px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.download-card details { width: 100%; margin-top: 18px; }
|
||||
.download-card summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; font-weight: 750; }
|
||||
.download-card pre { max-width: 100%; margin: 10px 0 0; padding: 14px; overflow: auto; border-radius: 8px; color: #dfe8d1; background: #171a24; font-size: 10px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.download-proof, .download-gated { display: flex; align-items: center; justify-content: space-between; gap: 60px; padding: 70px max(20px, calc((100vw - var(--max)) / 2)); color: white; background: var(--ink); }
|
||||
.download-proof > div:first-child, .download-gated > div:first-child { max-width: 720px; }
|
||||
.download-proof h2, .download-gated h2 { margin: 12px 0 0; font-size: clamp(36px, 4vw, 56px); letter-spacing: -.05em; }
|
||||
.download-proof p, .download-gated p { margin: 16px 0 0; color: #adb1c0; line-height: 1.7; }
|
||||
.download-proof-actions, .download-gated-actions { display: flex; min-width: 260px; flex-direction: column; gap: 10px; }
|
||||
.download-gated { min-height: 420px; color: var(--ink); background: #f0eadb; }
|
||||
.download-gated p { color: #655f53; }
|
||||
.compact-heading { width: auto; margin: 0 0 24px; }
|
||||
.compact-heading h2 { font-size: clamp(34px, 4vw, 52px); }
|
||||
.incident-list { display: grid; gap: 12px; }
|
||||
.incident-card { padding: 28px; border: 1px solid var(--line); border-left-width: 5px; border-radius: 14px; background: white; }
|
||||
.incident-card.incident-maintenance { border-left-color: #7184c1; }
|
||||
.incident-card.incident-degraded { border-left-color: #c79322; }
|
||||
.incident-card.incident-outage { border-left-color: var(--coral); }
|
||||
.incident-card-heading { display: flex; align-items: center; justify-content: space-between; gap: 18px; color: var(--muted); font-size: 11px; }
|
||||
.incident-card h3 { margin: 24px 0 10px; font-size: 28px; letter-spacing: -.035em; }
|
||||
.incident-card > p { max-width: 850px; margin: 0; line-height: 1.75; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.status-empty { min-height: 150px; display: flex; align-items: center; gap: 20px; padding: 28px; border: 1px solid #cbd8ae; border-radius: 14px; background: #eff8df; }
|
||||
.status-empty > span { width: 50px; height: 50px; display: grid; place-items: center; flex: none; border-radius: 50%; color: white; background: var(--lime-deep); font-size: 24px; }
|
||||
.status-empty strong { font-size: 20px; }
|
||||
.status-empty p { margin: 7px 0 0; color: var(--muted); }
|
||||
.incident-history-list { border-top: 1px solid var(--line); }
|
||||
.incident-history-list article { padding: 24px 0; border-bottom: 1px solid var(--line); }
|
||||
.incident-history-list article > div { display: flex; align-items: baseline; justify-content: space-between; gap: 18px; }
|
||||
.incident-history-list span, .status-history-empty { color: var(--muted); font-size: 11px; }
|
||||
.incident-history-list p { margin: 10px 0 0; color: var(--muted); line-height: 1.65; }
|
||||
.admin-incident-layout { display: grid; grid-template-columns: .75fr 1.25fr; gap: 36px; padding-top: 24px; }
|
||||
.admin-incident-layout h3 { margin: 0; font-size: 22px; }
|
||||
.admin-incident-layout > div > p { color: var(--muted); line-height: 1.6; }
|
||||
.admin-incident-list > article { padding: 22px 0; border-bottom: 1px solid var(--line); }
|
||||
.admin-incident-list > article:last-child { border-bottom: 0; }
|
||||
.admin-incident-list h4 { margin: 18px 0 8px; font-size: 18px; }
|
||||
.admin-incident-list article > p { margin: 0; line-height: 1.65; white-space: pre-wrap; }
|
||||
.incident-resolve-form { margin-top: 16px; padding: 16px; border: 1px solid #dddcd2; border-radius: 10px; background: #f8f8f2; }
|
||||
.admin-deletion-list { margin-top: 20px; border-top: 1px solid var(--line); }
|
||||
.admin-deletion-list article { display: grid; grid-template-columns: minmax(240px, .8fr) minmax(240px, 1fr) 300px; gap: 18px; align-items: center; padding: 20px 0; border-bottom: 1px solid var(--line); }
|
||||
.admin-deletion-list article > div { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||
.admin-deletion-list article > div span:last-child { width: 100%; color: var(--muted); font-size: 10px; }
|
||||
.admin-deletion-list article > p { margin: 0; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.admin-deletion-list code { color: #495897; font-size: 10px; overflow-wrap: anywhere; }
|
||||
|
||||
.route-state-shell { min-height: 100vh; display: grid; place-items: center; padding: 40px 20px; background: radial-gradient(circle at 20% 15%, rgb(202 255 105 / 20%), transparent 28%), var(--paper); }
|
||||
.route-state-card { width: min(680px, 100%); padding: clamp(30px, 6vw, 58px); border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--white); box-shadow: var(--shadow); }
|
||||
.route-state-kicker { display: block; margin-bottom: 18px; color: #697443; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .13em; }
|
||||
.route-state-card h1 { margin: 0; font-size: clamp(34px, 6vw, 58px); line-height: 1.04; letter-spacing: -.05em; }
|
||||
.route-state-card > p { max-width: 570px; margin: 20px 0 0; color: var(--muted); line-height: 1.7; }
|
||||
.route-state-note { padding: 15px 17px; border-left: 4px solid #d4a92c; background: #fff6d7; color: #59491e !important; font-size: 12px; }
|
||||
.route-state-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 30px; }
|
||||
.route-state-link { min-height: 44px; display: inline-flex; align-items: center; padding: 0 8px; color: #3f579d; font-size: 13px; font-weight: 750; text-decoration: underline; text-underline-offset: 3px; }
|
||||
.route-loading-lines { display: grid; gap: 10px; margin-top: 34px; }
|
||||
.route-loading-lines span { height: 13px; border-radius: 999px; background: linear-gradient(90deg, #e7e7dc 20%, #f6f6ee 45%, #e7e7dc 70%); background-size: 220% 100%; animation: route-loading 1.5s ease-in-out infinite; }
|
||||
.route-loading-lines span:nth-child(2) { width: 82%; }
|
||||
.route-loading-lines span:nth-child(3) { width: 56%; }
|
||||
@keyframes route-loading { from { background-position: 100% 0; } to { background-position: -100% 0; } }
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.public-nav { display: none; }
|
||||
.hero-grid { grid-template-columns: 1fr; }
|
||||
.hero-console { transform: none; }
|
||||
.relay-diagram { grid-template-columns: 1fr; gap: 10px; }
|
||||
.relay-arrow { min-height: 50px; justify-content: center; }
|
||||
.relay-arrow i { width: 1px; height: 26px; }
|
||||
.relay-arrow i::after { right: -3px; top: auto; bottom: 0; border-width: 7px 4px 0; border-color: #a9aa9e transparent transparent; }
|
||||
.relay-node, .relay-node:first-child, .relay-node:last-child { border-radius: 18px; transform: none; }
|
||||
.trust-grid { grid-template-columns: 1fr; gap: 50px; }
|
||||
.footer-grid { grid-template-columns: 1.2fr 1fr; }
|
||||
.pricing-catalog { grid-template-columns: 1fr; }
|
||||
.pricing-card > p { min-height: auto; }
|
||||
.boundary-grid { grid-template-columns: 1fr; }
|
||||
.boundary-card { min-height: 320px; }
|
||||
.gates-grid { grid-template-columns: 1fr 1fr; }
|
||||
.cloud-shell { grid-template-columns: 82px 1fr; }
|
||||
.cloud-sidebar { padding: 22px 12px; }
|
||||
.cloud-sidebar .brand-type, .prototype-notice, .cloud-nav a { font-size: 0; }
|
||||
.cloud-nav a { justify-content: center; padding: 0; }
|
||||
.cloud-nav a > span { width: auto; font-size: 15px; }
|
||||
.sidebar-account { grid-template-columns: 1fr; }
|
||||
.sidebar-account > span:nth-child(2), .sidebar-account > a { display: none; }
|
||||
.account-avatar { margin: 0 auto; }
|
||||
.metric-grid { grid-template-columns: 1fr 1fr; }
|
||||
.dashboard-columns { grid-template-columns: 1fr; }
|
||||
.admin-incident-layout { grid-template-columns: 1fr; }
|
||||
.admin-deletion-list article { grid-template-columns: 1fr; }
|
||||
.feedback-layout { grid-template-columns: 1fr; }
|
||||
.recovery-options { grid-template-columns: 1fr; }
|
||||
.path-panel { grid-template-columns: 1fr; gap: 28px; }
|
||||
.form-layout { grid-template-columns: 1fr; }
|
||||
.security-grid { grid-template-columns: 1fr; }
|
||||
.admin-metrics { grid-template-columns: 1fr 1fr; }
|
||||
.operations-grid { grid-template-columns: 1fr 1fr; }
|
||||
.admin-grid { grid-template-columns: 1fr; }
|
||||
.maintenance-status { grid-template-columns: 1fr; }
|
||||
.audit-list article { grid-template-columns: 110px 150px 1fr; padding: 12px 0; }
|
||||
.audit-list p, .audit-list code { grid-column: 2 / -1; }
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.public-header-inner { width: min(100% - 24px, var(--max)); min-height: 64px; }
|
||||
.brand-type span, .beta-chip { display: none; }
|
||||
.header-actions { margin-left: auto; }
|
||||
.hero-section { padding: 58px 0 64px; }
|
||||
.hero-grid, .section-heading, .relay-diagram, .steps-grid, .feature-ledger, .price-callout, .trust-grid { width: min(var(--max), calc(100% - 24px)); }
|
||||
.hero-copy h1 { font-size: clamp(46px, 14vw, 68px); }
|
||||
.hero-kicker { align-items: flex-start; flex-direction: column; }
|
||||
.hero-actions .button { width: 100%; }
|
||||
.hero-facts { gap: 16px; }
|
||||
.console-layout { grid-template-columns: 1fr; }
|
||||
.console-tree { display: none; }
|
||||
.console-chat { min-height: 420px; }
|
||||
.proof-strip { justify-content: flex-start; overflow-x: auto; flex-wrap: nowrap; }
|
||||
.proof-strip span { flex: none; padding: 16px 18px; }
|
||||
.section { padding: 72px 0; }
|
||||
.split-heading { grid-template-columns: 1fr; gap: 20px; margin-bottom: 42px; }
|
||||
.section-heading h2, .price-copy h2, .trust-title h2 { font-size: 42px; }
|
||||
.steps-grid { grid-template-columns: 1fr; }
|
||||
.steps-grid article { min-height: auto; }
|
||||
.feature-ledger article { grid-template-columns: 45px 1fr; }
|
||||
.feature-ledger article div { grid-template-columns: 1fr; gap: 10px; }
|
||||
.price-callout { grid-template-columns: 1fr; }
|
||||
.price-copy, .price-board { padding: 32px 24px; }
|
||||
.visibility-table > div { grid-template-columns: 1.2fr .8fr; padding: 12px 0; }
|
||||
.visibility-table > div > :nth-child(3) { grid-column: 1 / -1; }
|
||||
.visibility-head > :nth-child(3) { display: none; }
|
||||
.readiness-banner { align-items: stretch; flex-direction: column; padding: 54px 20px; }
|
||||
.readiness-banner .button { width: 100%; }
|
||||
.footer-grid { grid-template-columns: 1fr; gap: 34px; }
|
||||
.footer-bottom { flex-direction: column; }
|
||||
.subpage-hero-inner { width: calc(100% - 24px); padding: 72px 0 64px; }
|
||||
.subpage-hero h1 { font-size: 54px; }
|
||||
.pricing-catalog, .policy-grid, .comparison-table, .fine-print, .boundary-grid, .mutable-pwa-grid, .claim-grid, .gates-heading, .gates-grid, .legal-ledger, .data-map-grid, .privacy-action-grid, .privacy-open-items { width: calc(100% - 24px); }
|
||||
.pricing-card { padding: 26px; }
|
||||
.policy-grid, .mutable-pwa-grid, .gates-heading { grid-template-columns: 1fr; gap: 34px; }
|
||||
.rules-list > div { grid-template-columns: 1fr; gap: 8px; }
|
||||
.comparison-table > div { grid-template-columns: 1fr 1fr; padding: 16px 0; }
|
||||
.comparison-table > div > :nth-child(3) { grid-column: 2; }
|
||||
.comparison-table > div > :first-child { grid-row: 1 / span 2; }
|
||||
.comparison-head > :first-child { grid-row: auto; }
|
||||
.evidence-checks { grid-template-columns: 1fr; }
|
||||
.claim-grid { grid-template-columns: 1fr; }
|
||||
.trust-cta { align-items: stretch; flex-direction: column; }
|
||||
.trust-cta-actions { width: 100%; justify-content: stretch; }
|
||||
.trust-cta .button { width: 100%; }
|
||||
.gates-grid { grid-template-columns: 1fr; }
|
||||
.legal-ledger { grid-template-columns: 1fr; }
|
||||
.legal-ledger article:nth-child(odd) { border-right: 0; }
|
||||
.data-map-grid, .privacy-action-grid, .privacy-open-items { grid-template-columns: 1fr; }
|
||||
.data-map-card { min-height: auto; padding: 24px; }
|
||||
.privacy-open-items { gap: 26px; padding: 28px 22px; }
|
||||
.cloud-shell { display: block; }
|
||||
.cloud-sidebar { position: sticky; top: 0; z-index: 40; width: 100%; height: auto; display: grid; grid-template-columns: auto 1fr auto; align-items: center; padding: 10px 12px; }
|
||||
.cloud-brand { padding: 0; }
|
||||
.cloud-sidebar .brand-mark { width: 34px; height: 34px; }
|
||||
.cloud-nav { flex-direction: row; justify-content: center; }
|
||||
.cloud-nav a { width: 44px; min-height: 44px; }
|
||||
.sidebar-account { margin: 0; padding: 0; border: 0; }
|
||||
.account-avatar { width: 34px; height: 34px; }
|
||||
.cloud-main { min-height: calc(100vh - 54px); }
|
||||
.cloud-page { width: calc(100% - 24px); padding: 26px 0 70px; }
|
||||
.page-heading { min-height: auto; align-items: stretch; flex-direction: column; margin-bottom: 26px; }
|
||||
.page-heading h1 { font-size: 44px; }
|
||||
.page-actions .button { width: 100%; }
|
||||
.metric-grid { grid-template-columns: 1fr; }
|
||||
.metric-card { min-height: 145px; }
|
||||
.panel { padding: 20px; }
|
||||
.entitlement-bar { grid-template-columns: 1fr 1fr; }
|
||||
.entitlement-bar > div:nth-child(2) { border-right: 0; }
|
||||
.entitlement-bar > div { border-bottom: 1px solid #35394c; }
|
||||
.detailed-host { grid-template-columns: 44px 1fr auto; padding: 12px 0; }
|
||||
.detailed-host > div:nth-child(3), .detailed-host > div:nth-child(4) { grid-column: 2 / -1; }
|
||||
.pairing-list article, .order-list article { grid-template-columns: 1fr auto; padding: 14px 0; }
|
||||
.pairing-list article > span, .order-list article > span { grid-column: 1 / -1; }
|
||||
.pairing-list .pairing-cancel-action { grid-column: 1 / -1; }
|
||||
.pairing-list .pairing-cancel-action .button { width: 100%; }
|
||||
.pairing-result-actions, .pairing-result-actions > *, .pairing-result-actions .button { width: 100%; }
|
||||
.choice-grid { grid-template-columns: 1fr; }
|
||||
.registration-steps .button { width: 100%; }
|
||||
.billing-status-panel { grid-template-columns: 1fr; gap: 30px; padding: 24px; }
|
||||
.mini-price-list, .billing-rules { grid-template-columns: 1fr; }
|
||||
.quote-controls { grid-template-columns: 1fr; }
|
||||
.safety-actions { grid-template-columns: 1fr; }
|
||||
.inline-callout { align-items: stretch; flex-direction: column; }
|
||||
.inline-callout .button { width: 100%; }
|
||||
.admin-metrics { grid-template-columns: 1fr; }
|
||||
.operations-grid { grid-template-columns: 1fr; }
|
||||
.admin-module { min-height: auto; }
|
||||
.admin-form-grid { grid-template-columns: 1fr; }
|
||||
.service-incident-banner { align-items: flex-start; flex-direction: column; gap: 6px; padding: 12px 16px; }
|
||||
.service-incident-banner span { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||||
.connectivity-banner { align-items: flex-start; flex-direction: column; gap: 4px; padding: 12px 16px; text-align: left; }
|
||||
.route-state-actions { align-items: stretch; flex-direction: column; }
|
||||
.route-state-actions .button, .route-state-link { width: 100%; }
|
||||
.route-state-link { justify-content: center; }
|
||||
.status-hero { min-height: 390px; padding: 64px 20px; }
|
||||
.download-hero { min-height: 440px; padding: 64px 20px; }
|
||||
.download-heading { grid-template-columns: 1fr; }
|
||||
.download-grid { grid-template-columns: 1fr; }
|
||||
.download-proof, .download-gated { align-items: stretch; flex-direction: column; padding: 54px 20px; }
|
||||
.download-proof-actions, .download-gated-actions { min-width: 0; width: 100%; }
|
||||
.download-proof-actions .button, .download-gated-actions .button { width: 100%; }
|
||||
.status-section { width: calc(100% - 24px); margin-top: 48px; }
|
||||
.incident-history-list article > div { align-items: flex-start; flex-direction: column; gap: 6px; }
|
||||
.admin-gate-table article { grid-template-columns: 38px 1fr auto; padding: 12px 0; }
|
||||
.admin-gate-table article > span:last-child { grid-column: 2 / -1; text-align: left; }
|
||||
.audit-list article { grid-template-columns: 1fr; gap: 5px; }
|
||||
.audit-list p, .audit-list code { grid-column: auto; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html { scroll-behavior: auto; }
|
||||
.route-loading-lines span { animation: none; }
|
||||
*, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "NekoNest Cloud|把本地 coding-agent 接回手机",
|
||||
template: "%s|NekoNest Cloud",
|
||||
},
|
||||
description:
|
||||
"托管的私人 coding-agent 工作中继。项目、凭据和原生会话留在你的主机,手机负责继续与控制。",
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "zh_CN",
|
||||
title: "NekoNest Cloud|把本地 coding-agent 接回手机",
|
||||
description:
|
||||
"项目、CLI 凭据和原生会话留在你的主机;Cloud 负责托管中转与运维。",
|
||||
images: [
|
||||
{
|
||||
url: "/og.png",
|
||||
width: 1730,
|
||||
height: 909,
|
||||
alt: "手机通过 NekoNest Cloud 中继连接本地 coding-agent 工作站",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "NekoNest Cloud",
|
||||
description: "把电脑上的 coding-agent,接回手机继续。",
|
||||
images: ["/og.png"],
|
||||
},
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
shortcut: "/favicon.svg",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
themeColor: "#121522",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PublicShell, StatusPill } from "./components/Shells";
|
||||
import { getPublicCommercialSnapshot } from "@/db/repository";
|
||||
import { getPublicBetaPresentation } from "@/db/domain";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "把本地 coding-agent 接回手机",
|
||||
description:
|
||||
"NekoNest Cloud 让 Windows 与 Linux 主机主动出站连接,在手机上继续真实的本地 Codex、Claude Code、Kimi CLI 与 Grok Build 会话。",
|
||||
};
|
||||
|
||||
export default async function Home() {
|
||||
const commercial = await getPublicCommercialSnapshot();
|
||||
const betaActive = Boolean(commercial.beta);
|
||||
const publicBetaOpen = betaActive && commercial.blockedP0 === 0;
|
||||
const betaCopy = getPublicBetaPresentation(betaActive);
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<section className="hero-section">
|
||||
<div className="hero-grid">
|
||||
<div className="hero-copy">
|
||||
<div className="hero-kicker">
|
||||
<StatusPill tone={publicBetaOpen ? "good" : betaActive ? "warn" : "neutral"}>
|
||||
{publicBetaOpen ? betaCopy.status : betaActive ? "闭测免费 · 公开接入冻结" : betaCopy.status}
|
||||
</StatusPill>
|
||||
<span>{betaActive && !publicBetaOpen ? "免费政策已经预设;当前仅向明确受邀账户开放。" : betaCopy.subline}</span>
|
||||
</div>
|
||||
<h1>
|
||||
把电脑上的
|
||||
<br />
|
||||
<em>coding-agent</em>
|
||||
<br />
|
||||
接回手机继续。
|
||||
</h1>
|
||||
<p className="hero-lead">
|
||||
项目、CLI 凭据和原生会话留在你的 Windows 或 Linux 主机。NekoNest Cloud
|
||||
负责托管可达性与中转,手机是你的安全遥控面。
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<Link className="button button-primary button-large" href="/dashboard">
|
||||
{publicBetaOpen ? "连接自己的主机" : "进入控制台"}
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
<Link className="button button-ghost button-large" href="/trust">
|
||||
先看信任边界
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hero-facts" aria-label="产品关键事实">
|
||||
<span><strong>0</strong> 家庭入站端口</span>
|
||||
<span><strong>4</strong> 个现行 Agent</span>
|
||||
<span><strong>{betaCopy.factValue}</strong> {betaCopy.factLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hero-console" aria-label="移动控制台示意">
|
||||
<div className="console-topbar">
|
||||
<span className="console-dots" aria-hidden="true"><i /><i /><i /></span>
|
||||
<span>我的乐园 / home-lab</span>
|
||||
<StatusPill tone="good">主机在线</StatusPill>
|
||||
</div>
|
||||
<div className="console-layout">
|
||||
<div className="console-tree">
|
||||
<span className="tree-label">工作目录</span>
|
||||
<strong>D:\work\nekonest</strong>
|
||||
<div className="tree-agent active"><span>◎</span> Codex <small>全控制</small></div>
|
||||
<div className="tree-thread active">继续商业化探索</div>
|
||||
<div className="tree-thread">修复 daemon 重连</div>
|
||||
<div className="tree-agent"><span>◌</span> Claude Code <small>兼容续聊</small></div>
|
||||
<div className="tree-agent"><span>◌</span> Kimi CLI <small>兼容续聊</small></div>
|
||||
</div>
|
||||
<div className="console-chat">
|
||||
<div className="chat-meta">
|
||||
<span>Codex · 最后确认 21:48</span>
|
||||
<StatusPill tone="info">已提交</StatusPill>
|
||||
</div>
|
||||
<div className="message assistant">
|
||||
<span className="message-avatar">N</span>
|
||||
<p>免费公测权益已经就绪。接下来验证真实主机配对与恢复流程。</p>
|
||||
</div>
|
||||
<div className="message user"><p>继续,把上线门禁也放进后台。</p></div>
|
||||
<div className="delivery-row">
|
||||
<span className="pulse-dot" />
|
||||
业务确认来自主机,而不是把 WebSocket 写成功当成完成
|
||||
</div>
|
||||
<div className="composer-mock">
|
||||
<span>从手机继续这个原生线程…</span>
|
||||
<button type="button" aria-label="发送示意" disabled>↑</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="console-caption">界面示意,不代表 Cloud 运行了 Agent 或保存了原生会话正文。</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="proof-strip" aria-label="核心边界">
|
||||
<span>主机主动出站</span>
|
||||
<span>原生 store 为权威</span>
|
||||
<span>目录 → Agent → 线程</span>
|
||||
<span>Codex 全控制</span>
|
||||
<span>自托管永久免费</span>
|
||||
</section>
|
||||
|
||||
<section className="section light-section" id="how">
|
||||
<div className="section-heading split-heading">
|
||||
<div>
|
||||
<span className="eyebrow">HOW IT WORKS / 连接方式</span>
|
||||
<h2>Cloud 只站在该站的位置。</h2>
|
||||
</div>
|
||||
<p>
|
||||
它不替你运行模型,不拿你的 API Key,也不浏览任意文件。守护进程从主机主动连出,手机沿着原生会话结构继续工作。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relay-diagram">
|
||||
<div className="relay-node phone-node">
|
||||
<span className="node-number">01</span>
|
||||
<strong>手机 PWA</strong>
|
||||
<small>浏览 · 发送 · 控制 · 通知</small>
|
||||
</div>
|
||||
<div className="relay-arrow"><span>HTTPS / WSS</span><i /></div>
|
||||
<div className="relay-node cloud-node">
|
||||
<span className="node-number">02</span>
|
||||
<strong>NekoNest Cloud</strong>
|
||||
<small>认证 · 路由 · 托管运维</small>
|
||||
<b>不运行模型</b>
|
||||
</div>
|
||||
<div className="relay-arrow"><span>主机主动出站</span><i /></div>
|
||||
<div className="relay-node host-node">
|
||||
<span className="node-number">03</span>
|
||||
<strong>你的主机</strong>
|
||||
<small>Daemon · CLI · 原生 store · 项目</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="steps-grid">
|
||||
<article><span>1</span><h3>安装主机守护进程</h3><p>Windows 与 Linux 正式支持。家里无需公网 IP,也不开放入站端口。</p></article>
|
||||
<article><span>2</span><h3>一次性配对</h3><p>用短时配对码把主机接入自己的乐园。设备令牌独立、可撤销。</p></article>
|
||||
<article><span>3</span><h3>继续原生线程</h3><p>按目录、Agent 和线程发现历史;能力做不到就明确说明,不给无效按钮。</p></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section dark-section">
|
||||
<div className="section-heading split-heading inverse">
|
||||
<div>
|
||||
<span className="eyebrow">BUILT FOR CONTINUATION</span>
|
||||
<h2>不是又一个“问任何问题”的聊天站。</h2>
|
||||
</div>
|
||||
<p>它解决的是你离开电脑之后,真实的本地 agent 线程还要继续、解卡和完成。</p>
|
||||
</div>
|
||||
<div className="feature-ledger">
|
||||
<article>
|
||||
<span className="ledger-index">A</span>
|
||||
<div><h3>投递状态说人话</h3><p>传输成功不等于 Agent 接受。待确认、已接受、已提交、失败和无法确定被分别呈现。</p></div>
|
||||
</article>
|
||||
<article>
|
||||
<span className="ledger-index">B</span>
|
||||
<div><h3>能力按 Agent 明示</h3><p>Codex 提供完整控制;Claude Code、Kimi CLI、Grok Build 按已探测能力兼容续聊。</p></div>
|
||||
</article>
|
||||
<article>
|
||||
<span className="ledger-index">C</span>
|
||||
<div><h3>本地仍是主场</h3><p>新线程只能落在 daemon 已发现的原生项目目录;没有永久 ghost thread,也不随意浏览磁盘。</p></div>
|
||||
</article>
|
||||
<article>
|
||||
<span className="ledger-index">D</span>
|
||||
<div><h3>公测状态不含糊</h3><p>免费政策、闭测邀请、容量限制和上线门禁分别记录,不靠积分、余额或未来收费承诺兜底。</p></div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section price-section">
|
||||
<div className="price-callout">
|
||||
<div className="price-copy">
|
||||
<span className="eyebrow">FREE PUBLIC BETA</span>
|
||||
<h2>前几个月,先免费把产品跑通。</h2>
|
||||
<p>{publicBetaOpen ? "当前面向国内个人用户免费测试。无需支付方式,不生成报价或订单,也不会在结束时自动扣款。" : betaActive ? "免费测试政策已经预设,但公开接入仍由 P0 安全门禁冻结;受邀闭测账户免费,不需要支付方式。" : "免费公测政策已经结束,但收费功能仍未开放;后续决定会另行通知。"}</p>
|
||||
<ul className="check-list">
|
||||
<li>自托管版本永久免费</li>
|
||||
<li>没有积分、钱包或 Token 充值</li>
|
||||
<li>收费时机、方式和价格以后再定</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="price-board">
|
||||
<div className="beta-price-row"><span>{betaCopy.priceHeading}</span><strong>{betaCopy.priceValue}</strong><small>{betaCopy.priceDetail}</small></div>
|
||||
<div className="catalog-price-row">
|
||||
<span>报价与订单</span>
|
||||
<strong>未开放</strong>
|
||||
<small>公测结束也不会自动创建</small>
|
||||
</div>
|
||||
<div className="catalog-price-row">
|
||||
<span>未来收费方案</span>
|
||||
<strong>以后再定</strong>
|
||||
<small>以真实使用和成本数据为依据</small>
|
||||
</div>
|
||||
<Link href="/pricing">查看免费公测规则 →</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section trust-section">
|
||||
<div className="trust-grid">
|
||||
<div className="trust-title">
|
||||
<span className="eyebrow">TRUST IS A BOUNDARY</span>
|
||||
<h2>把能看见什么,写在产品正面。</h2>
|
||||
<p>密封传输是 Cloud 的目标门槛,但当前附件链路还没有完成生产级端到端实证。因此我们不会提前写“零知识”。</p>
|
||||
<Link className="text-link" href="/trust">查看可见性与风险边界 →</Link>
|
||||
</div>
|
||||
<div className="visibility-table" role="table" aria-label="数据可见性边界">
|
||||
<div role="row" className="visibility-head"><span>数据</span><span>主机</span><span>Cloud 控制面</span></div>
|
||||
<div role="row"><span>项目文件 / CLI 凭据</span><strong>保留</strong><em>不需要</em></div>
|
||||
<div role="row"><span>原生会话库</span><strong>权威来源</strong><em>不取代</em></div>
|
||||
<div role="row"><span>账号 / 主机 / 路由状态</span><strong>参与</strong><em>需要</em></div>
|
||||
<div role="row"><span>提示词 / 回复 / 附件明文</span><strong>处理</strong><em>目标是不需要*</em></div>
|
||||
<small>* 需以真实 sealed 命令与附件测试报告为准。</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="readiness-banner">
|
||||
<div>
|
||||
<StatusPill tone={commercial.blockedP0 ? "danger" : "good"}>{commercial.blockedP0 ? "免费公测尚未开放" : "公测门禁已通过"}</StatusPill>
|
||||
<h2>{commercial.blockedP0 ? "控制台能跑,不等于托管链路已经可用。" : "免费公测已经具备基础开放条件。"}</h2>
|
||||
<p>
|
||||
{commercial.blockedP0
|
||||
? `当前还有 ${commercial.blockedP0} 项免费公测 P0 门禁未通过,包括密封传输、租户隔离、主机认领、主体/域名路径和隐私保存。`
|
||||
: "P0 证据已经齐全;仍需按邀请范围和运营容量逐步开放。"}
|
||||
</p>
|
||||
</div>
|
||||
<Link className="button button-light button-large" href="/readiness">
|
||||
打开上线检查表
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
</section>
|
||||
</PublicShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PublicShell, StatusPill } from "../components/Shells";
|
||||
import { getPublicCommercialSnapshot } from "@/db/repository";
|
||||
import { getPublicBetaPresentation } from "@/db/domain";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "免费公测政策",
|
||||
description: "NekoNest Cloud 前几个月免费公测,不绑定支付方式,也不创建报价或订单。",
|
||||
};
|
||||
|
||||
export default async function PricingPage() {
|
||||
const commercial = await getPublicCommercialSnapshot();
|
||||
const betaActive = Boolean(commercial.beta);
|
||||
const publicBetaOpen = betaActive && commercial.blockedP0 === 0;
|
||||
const betaCopy = getPublicBetaPresentation(betaActive);
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<section className="subpage-hero pricing-hero">
|
||||
<div className="subpage-hero-inner">
|
||||
<span className="eyebrow">PUBLIC BETA / 免费公测</span>
|
||||
<h1>先把连接体验做稳,再讨论收费。</h1>
|
||||
<p>
|
||||
{publicBetaOpen
|
||||
? "当前面向国内个人用户免费测试,不绑定支付方式、不生成报价或订单,也不会在公测结束时自动扣款。"
|
||||
: betaActive
|
||||
? "免费测试政策已经预设,但公开接入仍被冻结;当前仅向管理员明确邀请的闭测账户免费开放。"
|
||||
: "当前免费公测政策已经结束,但收费功能仍未开放。后续方案确定前不会自动创建订单或扣款。"}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section pricing-catalog-section">
|
||||
<div className="pricing-catalog">
|
||||
<article className="pricing-card beta-card featured-card">
|
||||
<div className="pricing-card-top">
|
||||
<StatusPill tone={publicBetaOpen ? "good" : betaActive ? "warn" : "neutral"}>{publicBetaOpen ? betaCopy.cardStatus : betaActive ? "邀请闭测" : betaCopy.cardStatus}</StatusPill>
|
||||
<span>{betaCopy.cardTitle}</span>
|
||||
</div>
|
||||
<h2>{betaCopy.priceValue}</h2>
|
||||
<p>{betaCopy.cardDescription}</p>
|
||||
<ul className="plain-list">
|
||||
<li>无需绑定支付方式</li>
|
||||
<li>不创建报价、订单或余额</li>
|
||||
<li>公测结束不会自动扣款</li>
|
||||
<li>容量与反滥用限制提前明示</li>
|
||||
</ul>
|
||||
<Link className="button button-light" href="/dashboard">{betaCopy.cta}</Link>
|
||||
</article>
|
||||
|
||||
<article className="pricing-card">
|
||||
<div className="pricing-card-top">
|
||||
<StatusPill tone="info">以后再定</StatusPill>
|
||||
<span>未来收费</span>
|
||||
</div>
|
||||
<h2>尚未确定</h2>
|
||||
<p>什么时候收费、怎样收费以及具体价格,等真实用户量、资源成本和支持工作量有数据后再决定。</p>
|
||||
<ul className="plain-list">
|
||||
<li>不会把公测用户静默转成付费</li>
|
||||
<li>启用收费前会单独通知</li>
|
||||
<li>用户需要主动确认</li>
|
||||
<li>自托管路径继续免费</li>
|
||||
</ul>
|
||||
<button className="button button-secondary" type="button" disabled>收费功能未开放</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section policy-section">
|
||||
<div className="policy-grid">
|
||||
<div>
|
||||
<span className="eyebrow">BETA RULES</span>
|
||||
<h2>免费不等于规则不透明。</h2>
|
||||
</div>
|
||||
<dl className="rules-list">
|
||||
<div><dt>接入主机</dt><dd>后台可按公测容量调整允许接入的主机槽位;变更会在连接前明确显示。</dd></div>
|
||||
<div><dt>手机与浏览器</dt><dd>不消耗主机槽位,但仍受正常的安全、连接和反滥用限制。</dd></div>
|
||||
<div><dt>公测结束</dt><dd>只改变免费资格,不会生成订单、绑定支付方式或自动扣款。</dd></div>
|
||||
<div><dt>数据与退出</dt><dd>停用主机、撤销设备和必要的数据退出能力不应被未来收费状态锁住。</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section comparison-section">
|
||||
<div className="section-heading split-heading">
|
||||
<div><span className="eyebrow">SELF-HOST OR CLOUD</span><h2>自己部署,或参加免费公测。</h2></div>
|
||||
<p>Cloud 现阶段的目标是验证官方托管能否真正省掉部署与维护成本,而不是急着做支付系统。</p>
|
||||
</div>
|
||||
<div className="comparison-table" role="table" aria-label="自托管与 Cloud 比较">
|
||||
<div role="row" className="comparison-head"><span>项目</span><strong>自托管</strong><strong>NekoNest Cloud</strong></div>
|
||||
<div role="row"><span>当前费用</span><b>免费 / 开源</b><b>{betaCopy.comparison}</b></div>
|
||||
<div role="row"><span>VPS、DNS、TLS</span><b>自己维护</b><b>公测平台负责</b></div>
|
||||
<div role="row"><span>项目与模型凭据</span><b>留在主机</b><b>仍留在主机</b></div>
|
||||
<div role="row"><span>未来收费承诺</span><b>无</b><b>目前未确定</b></div>
|
||||
</div>
|
||||
</section>
|
||||
</PublicShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
export type DataInventoryGroup = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "active" | "security" | "operational" | "dormant";
|
||||
summary: string;
|
||||
examples: readonly string[];
|
||||
purpose: string;
|
||||
retention: string;
|
||||
userControl: string;
|
||||
tables: readonly string[];
|
||||
};
|
||||
|
||||
export const DATA_INVENTORY: readonly DataInventoryGroup[] = [
|
||||
{
|
||||
id: "account",
|
||||
title: "账户与登录身份",
|
||||
status: "active",
|
||||
summary: "识别登录用户,并把 Cloud 资源隔离到正确账户。",
|
||||
examples: ["账户 ID", "登录提供方主体标识", "邮箱", "显示名称", "账户状态与时间"],
|
||||
purpose: "登录、账户隔离、管理员核对和用户数据导出。",
|
||||
retention: "最终保存期尚未确定;当前随账户保留,注销申请进入人工核对队列。",
|
||||
userControl: "控制台可下载账户范围导出,并提交或撤回注销申请。",
|
||||
tables: ["accounts"],
|
||||
},
|
||||
{
|
||||
id: "host-security",
|
||||
title: "主机、设备与配对安全",
|
||||
status: "security",
|
||||
summary: "把用户主机安全地认领到所属账户,并支持撤销和恢复。",
|
||||
examples: ["主机名称与 OS", "daemon 版本", "公钥与身份指纹", "配对状态", "令牌摘要", "限速来源摘要"],
|
||||
purpose: "配对认领、设备认证、槽位管理、重放拒绝、限速和安全恢复。",
|
||||
retention: "明文配对码十分钟失效且不写入 D1;成功认领立即烧毁原摘要,其他过期摘要在维护时转为 tombstone。丢失响应恢复只保存由 daemon 一次性 retry key 加密的响应,十分钟后删除。来源限速窗口保留 24 小时,配对尝试保留 30 天。主机历史与凭据摘要的最终保存期尚未确定。",
|
||||
userControl: "可取消未认领配对、撤销主机令牌;账户导出不包含令牌、配对码、摘要或内部密钥。",
|
||||
tables: [
|
||||
"hosts",
|
||||
"pairing_requests",
|
||||
"device_credentials",
|
||||
"pairing_claim_rate_limits",
|
||||
"pairing_claim_attempts",
|
||||
"device_registration_replays",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tenant-runtime",
|
||||
title: "租户、区域与授权状态",
|
||||
status: "operational",
|
||||
summary: "记录租户所属主区域、唯一 Relay placement generation 与当前授权 revision。",
|
||||
examples: ["租户 ID 与 slug", "home region", "placement generation", "迁移阶段与备份摘要", "authorization revision"],
|
||||
purpose: "稳定端点路由、唯一写入节点 fencing、可回滚迁移、租户暂停和设备撤销传播。",
|
||||
retention: "管理员只能针对已确认的注销申请启动永久逻辑删除;Relay 会先关闭 Engine,再删除实时 SQLite、附件和该租户全部备份,并回传摘要证据。云存储物理块擦除、法定保留和最终账户身份清除仍须按上线政策核定。",
|
||||
userControl: "用户可先提交并在处理前撤回注销申请;删除启动后访问立即暂停且不可撤回。账户导出不包含内部节点地址或删除账本。",
|
||||
tables: ["tenant_instances", "tenant_placements", "tenant_authorization_state", "relay_migrations", "relay_purge_jobs"],
|
||||
},
|
||||
{
|
||||
id: "relay-infrastructure",
|
||||
title: "Relay 区域、节点与内部身份",
|
||||
status: "security",
|
||||
summary: "维护共享 Relay 池的区域、节点、签名公钥和经 mTLS 绑定的节点凭据摘要。",
|
||||
examples: ["区域代码", "节点状态", "证书指纹", "SPIFFE ID", "签名 kid 与公钥"],
|
||||
purpose: "节点认证、授权快照签名、容量调度、故障隔离和密钥轮换。",
|
||||
retention: "当前作为安全与运维状态保留;D1 不保存签名私钥或明文节点 bearer,轮换和退役记录的最终期限尚未确定。",
|
||||
userControl: "属于内部基础设施元数据,不进入账户自助导出;用户数据导出不暴露节点地址、凭据摘要或密钥引用。",
|
||||
tables: ["relay_regions", "relay_nodes", "relay_signing_keys", "relay_node_credentials"],
|
||||
},
|
||||
{
|
||||
id: "phone-relay-access",
|
||||
title: "手机 handoff 与 Relay 路由凭据",
|
||||
status: "security",
|
||||
summary: "把 Cloud 登录会话一次性交给目标 Relay,并验证后续手机路由和手机凭据属于同一租户。",
|
||||
examples: ["handoff ticket 摘要", "预期 PWA origin", "route handle 摘要", "phone token 摘要", "手机 E2E 公钥"],
|
||||
purpose: "单次 handoff、防重放、租户路由、可撤销手机身份和 sealed E2E 配对。",
|
||||
retention: "D1 从不保存明文 ticket、route handle 或 phone token;已消费或过期 ticket 在 24 小时后由维护任务删除,route 与 phone principal 随撤销和租户删除策略处理。",
|
||||
userControl: "Cloud 登录不自动授予任何设备访问;手机仍须逐设备配对,撤销 phone principal 会同时使对应 route handle 失效。",
|
||||
tables: ["phone_handoff_tickets", "phone_route_handles", "relay_phone_principals"],
|
||||
},
|
||||
{
|
||||
id: "beta-entitlement",
|
||||
title: "免费公测资格",
|
||||
status: "active",
|
||||
summary: "决定当前账户可以接入多少台主机,不用于积分或余额。",
|
||||
examples: ["公测开关", "容量上限", "闭测申请场景", "闭测邀请", "起止时间", "处理状态与说明"],
|
||||
purpose: "公开测试容量控制、闭测申请审核、邀请签发和到期边界。",
|
||||
retention: "申请、政策和权益记录当前作为状态及审计证据随账户保留;最终保存期尚未确定。",
|
||||
userControl: "控制台可提交或撤回一条待处理申请,查看审核说明、当前资格、容量和下一次变化;不会据此自动创建订单或扣款。",
|
||||
tables: ["beta_programs", "entitlement_grants", "beta_access_requests"],
|
||||
},
|
||||
{
|
||||
id: "support-lifecycle",
|
||||
title: "反馈与账户退出",
|
||||
status: "active",
|
||||
summary: "接收公测问题、管理员回复,以及记录可撤回的注销意愿。",
|
||||
examples: ["反馈分类与正文", "管理员回复", "处理状态", "注销原因", "申请与撤回时间"],
|
||||
purpose: "解决公测问题,并让账户退出流程可追踪而不是只隐藏界面。",
|
||||
retention: "最终保存期和注销完成时限尚未确定;真实租户卷与备份擦除闭环完成前不会伪装成已删除。",
|
||||
userControl: "反馈和注销记录进入账户导出;注销申请在处理前可以撤回。",
|
||||
tables: ["beta_feedback", "account_deletion_requests"],
|
||||
},
|
||||
{
|
||||
id: "operations",
|
||||
title: "服务状态、审计与幂等记录",
|
||||
status: "operational",
|
||||
summary: "证明管理员做过什么、故障何时发生,并阻止重复写操作。",
|
||||
examples: ["上线门禁", "故障公告", "动作与对象 ID", "变更前后摘要", "幂等请求摘要", "自动清理状态", "schema 版本"],
|
||||
purpose: "服务公告、故障恢复、管理动作追溯、重复请求防护、到期数据最小化和数据库安全升级。",
|
||||
retention: "幂等记录带技术到期时间;每日自动清理与管理员手工回退处理既有技术记录及已消费或过期 24 小时的 handoff ticket。自动任务只覆盖一条最近状态和累计计数,不保存逐次运行历史;审计及其他记录的最终保存期仍是公测门禁。",
|
||||
userControl: "公开状态和门禁可直接查看;内部审计、请求摘要和迁移账本不进入自助账户导出。",
|
||||
tables: [
|
||||
"launch_gates",
|
||||
"service_incidents",
|
||||
"audit_events",
|
||||
"idempotency_records",
|
||||
"maintenance_jobs",
|
||||
"cloud_schema_migrations",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "dormant-billing",
|
||||
title: "休眠的收费表结构",
|
||||
status: "dormant",
|
||||
summary: "为未来可能的收费保留结构,但免费公测期间禁止创建业务记录。",
|
||||
examples: ["价格版本", "订单", "付款尝试", "发票", "退款"],
|
||||
purpose: "当前没有用户用途;相关 API 服务端拒绝写入,未来启用前必须重新决策和审查。",
|
||||
retention: "免费公测期间不应产生这类记录;若未来启用,须先另行确定保存、退款和财税规则。",
|
||||
userControl: "没有报价、支付或余额入口,也不会在公测结束时自动扣款。",
|
||||
tables: ["price_versions", "orders", "payment_attempts", "invoices", "refunds"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const INVENTORIED_TABLES = DATA_INVENTORY.flatMap((group) => group.tables);
|
||||
|
||||
export const CONTROL_PLANE_EXCLUSIONS = [
|
||||
{
|
||||
item: "项目文件和任意磁盘目录内容",
|
||||
boundary: "不应上传到 Cloud 控制面。",
|
||||
},
|
||||
{
|
||||
item: "coding-agent 原生会话库与 transcript",
|
||||
boundary: "由本地主机的原生 store 管理,不进入控制平面 D1。",
|
||||
},
|
||||
{
|
||||
item: "Agent CLI、模型账户和 API Key",
|
||||
boundary: "只留在用户主机,Cloud 不需要这些凭据。",
|
||||
},
|
||||
{
|
||||
item: "明文配对码、明文设备/手机/节点令牌和签名私钥",
|
||||
boundary: "仅在必要的签发或请求中短暂处理,不持久化到 D1;D1 只保存摘要。",
|
||||
},
|
||||
] as const;
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PublicShell, StatusPill } from "../components/Shells";
|
||||
import { CONTROL_PLANE_EXCLUSIONS, DATA_INVENTORY } from "./data-inventory";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "公测数据说明",
|
||||
description: "NekoNest Cloud 免费公测当前保存的数据、用途、退出方式和尚未完成的保存与删除边界。",
|
||||
};
|
||||
|
||||
const statusCopy = {
|
||||
active: { label: "公测使用", tone: "good" },
|
||||
security: { label: "安全必需", tone: "info" },
|
||||
operational: { label: "运维必需", tone: "neutral" },
|
||||
dormant: { label: "未启用", tone: "warn" },
|
||||
} as const;
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<PublicShell>
|
||||
<section className="subpage-hero privacy-hero">
|
||||
<div className="subpage-hero-inner">
|
||||
<span className="eyebrow">BETA DATA MAP / 公测数据说明</span>
|
||||
<h1>先把实际保存的数据说清楚。</h1>
|
||||
<p>这不是一份拿模板拼出的最终隐私政策,而是与当前控制平面代码对齐的数据清单:保存什么、为什么需要、用户能做什么,以及哪些保存与删除问题仍没有完成。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section data-map-section">
|
||||
<div className="section-heading split-heading">
|
||||
<div><span className="eyebrow">CURRENT INVENTORY</span><h2>当前控制平面的完整记录类型。</h2></div>
|
||||
<p>收费表结构被单独标为休眠;它们存在不等于公测期间会生成订单或付款记录。</p>
|
||||
</div>
|
||||
<div className="data-map-grid">
|
||||
{DATA_INVENTORY.map((group) => {
|
||||
const status = statusCopy[group.status];
|
||||
return (
|
||||
<article className={`data-map-card data-map-${group.status}`} key={group.id}>
|
||||
<div className="data-map-card-heading">
|
||||
<span>{group.id.toUpperCase()}</span>
|
||||
<StatusPill tone={status.tone}>{status.label}</StatusPill>
|
||||
</div>
|
||||
<h3>{group.title}</h3>
|
||||
<p>{group.summary}</p>
|
||||
<ul className="data-example-list">
|
||||
{group.examples.map((example) => <li key={example}>{example}</li>)}
|
||||
</ul>
|
||||
<dl className="data-map-details">
|
||||
<div><dt>用途</dt><dd>{group.purpose}</dd></div>
|
||||
<div><dt>保存</dt><dd>{group.retention}</dd></div>
|
||||
<div><dt>用户控制</dt><dd>{group.userControl}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section not-collected-section">
|
||||
<div className="policy-grid">
|
||||
<div><span className="eyebrow">CONTROL-PLANE BOUNDARY</span><h2>不上传,或不持久化。</h2></div>
|
||||
<div>
|
||||
<ul className="not-collected-list">
|
||||
{CONTROL_PLANE_EXCLUSIONS.map((entry) => <li key={entry.item}><strong>{entry.item}</strong><span>{entry.boundary}</span></li>)}
|
||||
</ul>
|
||||
<p className="privacy-boundary-note">sealed 中继和附件尚未完成真实端到端实证,因此这里描述的是控制平面设计与当前持久化边界,不是“运营方绝对无法看到明文”的承诺。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section privacy-actions-section">
|
||||
<div className="section-heading split-heading">
|
||||
<div><span className="eyebrow">YOUR CONTROLS</span><h2>已经可以使用的数据出口。</h2></div>
|
||||
<p>登录后可以下载账户范围 JSON、撤销主机令牌,以及提交或撤回注销申请。原生会话和项目文件仍由用户在自己的主机管理。</p>
|
||||
</div>
|
||||
<div className="privacy-action-grid">
|
||||
<article><span>01</span><h3>下载 Cloud 数据</h3><p>导出账户、主机、配对、免费权益、租户运行态、反馈和注销申请。</p><Link href="/dashboard/security">前往安全与设备 →</Link></article>
|
||||
<article><span>02</span><h3>立即撤销主机</h3><p>设备令牌立即失效并释放槽位,不受现在或未来的收费状态阻止。</p><Link href="/dashboard/hosts">管理主机 →</Link></article>
|
||||
<article><span>03</span><h3>申请账户注销</h3><p>申请可以撤回;租户卷、备份和保留例外核对完成前不会误报为已经删除。</p><Link href="/dashboard/security">查看账户动作 →</Link></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="privacy-open-items" aria-labelledby="privacy-open-heading">
|
||||
<div>
|
||||
<span className="eyebrow">STILL BLOCKING PUBLIC BETA</span>
|
||||
<h2 id="privacy-open-heading">这份清单完成了,但隐私门禁还没有通过。</h2>
|
||||
</div>
|
||||
<p>仍需确定每类数据的最终保存期、租户卷与备份擦除流程、必要保留例外、受托服务方、部署与跨境事实,以及公开登录方案。证据完成前,本站不会宣称“已经合规”。</p>
|
||||
</section>
|
||||
</PublicShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PublicShell, StatusPill } from "../components/Shells";
|
||||
import { getPublicCommercialSnapshot } from "@/db/repository";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "免费公测上线门禁",
|
||||
description: "免费公测开放前必须完成的安全、主机接入、主体路径、隐私与运维证据。",
|
||||
};
|
||||
|
||||
const categoryName: Record<string, string> = {
|
||||
security: "安全",
|
||||
entitlement: "权益",
|
||||
infrastructure: "基础设施",
|
||||
billing: "账单与支付",
|
||||
compliance: "经营与合规",
|
||||
privacy: "隐私与数据",
|
||||
operations: "运维",
|
||||
product: "产品规则",
|
||||
};
|
||||
|
||||
export default async function ReadinessPage() {
|
||||
const commercial = await getPublicCommercialSnapshot();
|
||||
return (
|
||||
<PublicShell>
|
||||
<section className="subpage-hero readiness-hero">
|
||||
<div className="subpage-hero-inner">
|
||||
<StatusPill tone={commercial.blockedP0 ? "danger" : "good"}>{commercial.blockedP0 ? "PUBLIC BETA BLOCKED" : "P0 GATES PASSED"}</StatusPill>
|
||||
<h1>{commercial.blockedP0 ? "先把免费公测跑稳。" : "免费公测基础门禁已经通过。"}</h1>
|
||||
<p>{commercial.blockedP0 ? `控制台可以演示数据和管理流程;真实主机接入、生产租户开通和强隐私承诺仍由 ${commercial.blockedP0} 项 P0 证据门禁阻止。` : "P0 证据已经齐全;接下来按邀请范围、支持能力和运营容量逐步开放。"}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section gates-section">
|
||||
<div className="gates-heading">
|
||||
<div><span className="eyebrow">P0 BETA GATES</span><h2>一项没过,就不向公众开放。</h2></div>
|
||||
<p>门禁状态来自控制平面数据库。管理员可以补负责人、证据与审计,但不能用一个“公测免费”开关绕过安全与运行条件。</p>
|
||||
</div>
|
||||
<div className="gates-grid">
|
||||
{commercial.gates.map((gate) => (
|
||||
<article className="gate-card" key={gate.key}>
|
||||
<div className="gate-card-top">
|
||||
<span>{categoryName[gate.category] ?? gate.category}</span>
|
||||
<StatusPill tone={gate.status === "passed" ? "good" : gate.status === "in_progress" ? "warn" : "danger"}>
|
||||
{gate.status === "passed" ? "已通过" : gate.status === "in_progress" ? "进行中" : "阻止"}
|
||||
</StatusPill>
|
||||
</div>
|
||||
<h3>{gate.title}</h3>
|
||||
<dl>
|
||||
<div><dt>负责人</dt><dd>{gate.owner || "待指定"}</dd></div>
|
||||
<div><dt>证据</dt><dd>{gate.evidence_url ? <a href={gate.evidence_url}>打开证据</a> : "尚未提交"}</dd></div>
|
||||
</dl>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section legal-section">
|
||||
<div className="section-heading split-heading">
|
||||
<div><span className="eyebrow">DOMESTIC BETA BASICS</span><h2>国内个人用户公测,先确认这些基础项。</h2></div>
|
||||
<p>当前不收款,因此支付、发票和付费条款不阻塞免费测试;主体/域名路径、隐私告知和真实运维能力仍要在开放前讲清楚。</p>
|
||||
</div>
|
||||
<div className="legal-ledger">
|
||||
<article><span>01</span><h3>主体、域名与接入路径</h3><p>确认以什么身份提供免费测试、域名和部署位置,以及当前服务形态对应的备案路径。</p></article>
|
||||
<article><span>02</span><h3>隐私告知与数据退出</h3><p>当前数据清单与退出入口已公开;仍需确定保存期、受托方、跨境事实和卷/备份擦除证据。</p><a href="/privacy">查看公测数据说明 →</a></article>
|
||||
<article><span>03</span><h3>真实主机接入安全</h3><p>配对码认领、来源限速、设备撤销、凭据恢复和异常告警必须在真实 daemon 链路上验证。</p></article>
|
||||
<article><span>04</span><h3>故障与反馈渠道</h3><p>准备最小可用的状态通知、问题反馈、回滚和数据恢复路径,让测试用户知道出问题该怎么办。</p></article>
|
||||
</div>
|
||||
</section>
|
||||
</PublicShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { RouteErrorState } from "../components/RouteStates";
|
||||
|
||||
export default function StatusError({ reset }: { reset: () => void }) {
|
||||
return (
|
||||
<RouteErrorState
|
||||
area="服务状态"
|
||||
title="状态页暂时不可用"
|
||||
description="这次没有读取到可信的服务公告,因此不会显示推测的“服务正常”。"
|
||||
reset={reset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { RouteLoadingState } from "../components/RouteStates";
|
||||
|
||||
export default function StatusLoading() {
|
||||
return (
|
||||
<RouteLoadingState
|
||||
area="服务状态"
|
||||
description="正在核对免费公测服务的最新公告和恢复记录。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { PublicShell, StatusPill, formatDate } from "../components/Shells";
|
||||
import { getServiceStatusSnapshot } from "@/db/repository";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const statusCopy = {
|
||||
operational: {
|
||||
label: "服务正常",
|
||||
tone: "good" as const,
|
||||
summary: "目前没有正在处理的服务故障或维护公告。",
|
||||
},
|
||||
maintenance: {
|
||||
label: "计划维护",
|
||||
tone: "info" as const,
|
||||
summary: "部分能力正在维护,请按公告中的建议操作。",
|
||||
},
|
||||
degraded: {
|
||||
label: "服务降级",
|
||||
tone: "warn" as const,
|
||||
summary: "部分用户可能遇到延迟、重连或接入异常。",
|
||||
},
|
||||
outage: {
|
||||
label: "服务中断",
|
||||
tone: "danger" as const,
|
||||
summary: "当前存在影响使用的服务中断,我们正在处理。",
|
||||
},
|
||||
};
|
||||
|
||||
export default async function ServiceStatusPage() {
|
||||
const snapshot = await getServiceStatusSnapshot();
|
||||
const copy = statusCopy[snapshot.status];
|
||||
const resolved = snapshot.recentIncidents.filter(
|
||||
(incident) => incident.status === "resolved",
|
||||
);
|
||||
|
||||
return (
|
||||
<PublicShell serviceStatus={snapshot}>
|
||||
<div className="public-page status-page">
|
||||
<header className={`status-hero status-hero-${snapshot.status}`}>
|
||||
<StatusPill tone={copy.tone}>{copy.label}</StatusPill>
|
||||
<h1>{copy.summary}</h1>
|
||||
<p>
|
||||
这里发布 NekoNest Cloud 免费公测的维护、降级和中断信息。主机自身离线但这里显示正常时,请先查看控制台接入状态。
|
||||
</p>
|
||||
<small>最近核对:{formatDate(snapshot.checkedAt, true)}</small>
|
||||
</header>
|
||||
|
||||
<section className="status-section">
|
||||
<div className="section-heading compact-heading">
|
||||
<span className="eyebrow">ACTIVE / 处理中</span>
|
||||
<h2>当前事件</h2>
|
||||
</div>
|
||||
{snapshot.activeIncidents.length ? (
|
||||
<div className="incident-list">
|
||||
{snapshot.activeIncidents.map((incident) => (
|
||||
<article className={`incident-card incident-${incident.severity}`} key={incident.id}>
|
||||
<div className="incident-card-heading">
|
||||
<StatusPill tone={statusCopy[incident.severity].tone}>
|
||||
{statusCopy[incident.severity].label}
|
||||
</StatusPill>
|
||||
<span>{formatDate(incident.started_at, true)}</span>
|
||||
</div>
|
||||
<h3>{incident.title}</h3>
|
||||
<p>{incident.message}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="status-empty">
|
||||
<span aria-hidden="true">✓</span>
|
||||
<div><strong>没有正在处理的事件</strong><p>如果你仍然无法连接主机,请在控制台提交问题反馈。</p></div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="status-section status-history">
|
||||
<div className="section-heading compact-heading">
|
||||
<span className="eyebrow">HISTORY / 最近恢复</span>
|
||||
<h2>事件记录</h2>
|
||||
</div>
|
||||
{resolved.length ? (
|
||||
<div className="incident-history-list">
|
||||
{resolved.map((incident) => (
|
||||
<article key={incident.id}>
|
||||
<div><strong>{incident.title}</strong><span>{formatDate(incident.resolved_at, true)}</span></div>
|
||||
<p>{incident.resolution}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="status-history-empty">还没有已恢复的公开事件。</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</PublicShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PublicShell, StatusPill } from "../components/Shells";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "信任与隐私边界",
|
||||
description: "NekoNest Cloud 能看到什么、不能承诺什么,以及 sealed 上线前需要哪些证据。",
|
||||
};
|
||||
|
||||
export default function TrustPage() {
|
||||
return (
|
||||
<PublicShell>
|
||||
<section className="subpage-hero trust-hero">
|
||||
<div className="subpage-hero-inner">
|
||||
<span className="eyebrow">TRUST / 信任边界</span>
|
||||
<h1>安全不是一句“零知识”。</h1>
|
||||
<p>我们把数据路径、可见元数据、可变 PWA 风险和尚未完成的证明一起写出来。能力没有被实测,就不先拿来营销。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section boundary-section">
|
||||
<div className="boundary-grid">
|
||||
<article className="boundary-card local-card">
|
||||
<span className="boundary-icon">HOST</span>
|
||||
<h2>只应留在你的主机</h2>
|
||||
<ul className="plain-list">
|
||||
<li>项目文件与任意磁盘内容</li>
|
||||
<li>Agent CLI 凭据、模型账户和 API Key</li>
|
||||
<li>各 Agent 的原生会话库</li>
|
||||
<li>本地进程执行与原生 ownership</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article className="boundary-card cloud-boundary-card">
|
||||
<span className="boundary-icon">CLOUD</span>
|
||||
<h2>控制面确实需要</h2>
|
||||
<ul className="plain-list">
|
||||
<li>账户、手机、主机与租户标识</li>
|
||||
<li>认证、路由、连接状态与时间戳</li>
|
||||
<li>主机槽位、公测权益与审计状态</li>
|
||||
<li>必要的速率、大小和安全事件元数据</li>
|
||||
</ul>
|
||||
</article>
|
||||
<article className="boundary-card evidence-card">
|
||||
<span className="boundary-icon">PROVE</span>
|
||||
<h2>目标是不需要正文</h2>
|
||||
<p>sealed 模式目标是让中继不需要提示词、回复、工具内容和附件明文,也不持有可用解密钥。</p>
|
||||
<StatusPill tone="danger">附件端到端实证未完成</StatusPill>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section mutable-pwa-section">
|
||||
<div className="mutable-pwa-grid">
|
||||
<div>
|
||||
<span className="eyebrow">THE MUTABLE PWA PROBLEM</span>
|
||||
<h2>浏览器里的加密代码,也来自 Cloud。</h2>
|
||||
</div>
|
||||
<div>
|
||||
<p>即使中继只保存密文,托管方仍能更新手机端 PWA。被入侵或恶意的新版脚本可能在加密前读取明文。当前响应头基线不能解决这个根本问题;在没有严格 CSP、构建来源、依赖锁定、可验证发布和服务工作者回滚之前,不能声称“运营方永远不可能看到数据”。</p>
|
||||
<div className="evidence-checks">
|
||||
<span>✓ 当前无远程第三方脚本</span>
|
||||
<span>□ nonce/hash 严格 CSP(当前仅基线)</span>
|
||||
<span>□ 构建哈希 / provenance</span>
|
||||
<span>□ 服务工作者安全回滚</span>
|
||||
<span>□ sealed 命令与附件测试报告</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section claim-section">
|
||||
<div className="claim-grid">
|
||||
<div className="claim-do">
|
||||
<span>现在可以准确地说</span>
|
||||
<h3>主机主动出站,Cloud 不运行模型;项目、凭据与原生 store 留在主机。</h3>
|
||||
</div>
|
||||
<div className="claim-dont">
|
||||
<span>证据完成前不能说</span>
|
||||
<h3>绝对零知识、附件已 E2E、运营方永远看不到、安全隔离与可靠灾备已经完成。</h3>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="trust-cta">
|
||||
<div><h2>把承诺绑定到证据。</h2><p>所有安全、经营、支付和隐私门禁都在同一个公开检查面展示。</p></div>
|
||||
<div className="trust-cta-actions"><Link className="button button-primary button-large" href="/privacy">查看公测数据说明 →</Link><Link className="button button-secondary button-large" href="/readiness">查看上线门禁</Link></div>
|
||||
</section>
|
||||
</PublicShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user